Move code to external package

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
This commit is contained in:
Gabriel Adrian Samfira 2023-07-21 15:30:22 +00:00
parent 14586f0154
commit da13cec2de
195 changed files with 56394 additions and 364 deletions

View file

@ -0,0 +1,182 @@
// Copyright 2022 Cloudbase Solutions SRL
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
package cloudconfig
import (
"crypto/x509"
"encoding/base64"
"fmt"
"strings"
"sync"
"github.com/cloudbase/garm-provider-common/defaults"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
)
func NewDefaultCloudInitConfig() *CloudInit {
return &CloudInit{
PackageUpgrade: true,
Packages: []string{
"curl",
"tar",
},
SystemInfo: &SystemInfo{
DefaultUser: DefaultUser{
Name: defaults.DefaultUser,
Home: fmt.Sprintf("/home/%s", defaults.DefaultUser),
Shell: defaults.DefaultUserShell,
Groups: defaults.DefaultUserGroups,
Sudo: "ALL=(ALL) NOPASSWD:ALL",
},
},
}
}
type DefaultUser struct {
Name string `yaml:"name"`
Home string `yaml:"home"`
Shell string `yaml:"shell"`
Groups []string `yaml:"groups,omitempty"`
Sudo string `yaml:"sudo"`
}
type SystemInfo struct {
DefaultUser DefaultUser `yaml:"default_user"`
}
type File struct {
Encoding string `yaml:"encoding"`
Content string `yaml:"content"`
Owner string `yaml:"owner"`
Path string `yaml:"path"`
Permissions string `yaml:"permissions"`
}
type CloudInit struct {
mux sync.Mutex
PackageUpgrade bool `yaml:"package_upgrade"`
Packages []string `yaml:"packages,omitempty"`
SSHAuthorizedKeys []string `yaml:"ssh_authorized_keys,omitempty"`
SystemInfo *SystemInfo `yaml:"system_info,omitempty"`
RunCmd []string `yaml:"runcmd,omitempty"`
WriteFiles []File `yaml:"write_files,omitempty"`
CACerts CACerts `yaml:"ca-certs,omitempty"`
}
type CACerts struct {
RemoveDefaults bool `yaml:"remove-defaults"`
Trusted []string `yaml:"trusted"`
}
func (c *CloudInit) AddCACert(cert []byte) error {
c.mux.Lock()
defer c.mux.Unlock()
if cert == nil {
return nil
}
roots := x509.NewCertPool()
if ok := roots.AppendCertsFromPEM(cert); !ok {
return fmt.Errorf("failed to parse CA cert bundle")
}
c.CACerts.Trusted = append(c.CACerts.Trusted, string(cert))
return nil
}
func (c *CloudInit) AddSSHKey(keys ...string) {
c.mux.Lock()
defer c.mux.Unlock()
// TODO(gabriel-samfira): Validate the SSH public key.
for _, key := range keys {
found := false
for _, val := range c.SSHAuthorizedKeys {
if val == key {
found = true
break
}
}
if !found {
c.SSHAuthorizedKeys = append(c.SSHAuthorizedKeys, key)
}
}
}
func (c *CloudInit) AddPackage(pkgs ...string) {
c.mux.Lock()
defer c.mux.Unlock()
for _, pkg := range pkgs {
found := false
for _, val := range c.Packages {
if val == pkg {
found = true
break
}
}
if !found {
c.Packages = append(c.Packages, pkg)
}
}
}
func (c *CloudInit) AddRunCmd(cmd string) {
c.mux.Lock()
defer c.mux.Unlock()
c.RunCmd = append(c.RunCmd, cmd)
}
func (c *CloudInit) AddFile(contents []byte, path, owner, permissions string) {
c.mux.Lock()
defer c.mux.Unlock()
for _, val := range c.WriteFiles {
if val.Path == path {
return
}
}
file := File{
Encoding: "b64",
Content: base64.StdEncoding.EncodeToString(contents),
Owner: owner,
Permissions: permissions,
Path: path,
}
c.WriteFiles = append(c.WriteFiles, file)
}
func (c *CloudInit) Serialize() (string, error) {
c.mux.Lock()
defer c.mux.Unlock()
ret := []string{
"#cloud-config",
}
asYaml, err := yaml.Marshal(c)
if err != nil {
return "", errors.Wrap(err, "marshaling to yaml")
}
ret = append(ret, string(asYaml))
return strings.Join(ret, "\n"), nil
}

View file

@ -0,0 +1,451 @@
// Copyright 2022 Cloudbase Solutions SRL
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
package cloudconfig
import (
"bytes"
"fmt"
"text/template"
"github.com/cloudbase/garm-provider-common/params"
"github.com/pkg/errors"
)
var CloudConfigTemplate = `#!/bin/bash
set -e
set -o pipefail
CALLBACK_URL="{{ .CallbackURL }}"
METADATA_URL="{{ .MetadataURL }}"
BEARER_TOKEN="{{ .CallbackToken }}"
if [ -z "$METADATA_URL" ];then
echo "no token is available and METADATA_URL is not set"
exit 1
fi
GITHUB_TOKEN=$(curl --retry 5 --retry-delay 5 --retry-connrefused --fail -s -X GET -H 'Accept: application/json' -H "Authorization: Bearer ${BEARER_TOKEN}" "${METADATA_URL}/runner-registration-token/")
function call() {
PAYLOAD="$1"
[[ $CALLBACK_URL =~ ^(.*)/status$ ]]
if [ -z "$BASH_REMATCH" ];then
CALLBACK_URL="${CALLBACK_URL}/status"
fi
curl --retry 5 --retry-delay 5 --retry-connrefused --fail -s -X POST -d "${PAYLOAD}" -H 'Accept: application/json' -H "Authorization: Bearer ${BEARER_TOKEN}" "${CALLBACK_URL}" || echo "failed to call home: exit code ($?)"
}
function sendStatus() {
MSG="$1"
call "{\"status\": \"installing\", \"message\": \"$MSG\"}"
}
function success() {
MSG="$1"
ID=$2
call "{\"status\": \"idle\", \"message\": \"$MSG\", \"agent_id\": $ID}"
}
function fail() {
MSG="$1"
call "{\"status\": \"failed\", \"message\": \"$MSG\"}"
exit 1
}
# This will echo the version number in the filename. Given a file name like: actions-runner-osx-x64-2.299.1.tar.gz
# this will output: 2.299.1
function getRunnerVersion() {
FILENAME="{{ .FileName }}"
[[ $FILENAME =~ ([0-9]+\.[0-9]+\.[0-9+]) ]]
echo $BASH_REMATCH
}
function getCachedToolsPath() {
CACHED_RUNNER="/opt/cache/actions-runner/latest"
if [ -d "$CACHED_RUNNER" ];then
echo "$CACHED_RUNNER"
return 0
fi
VERSION=$(getRunnerVersion)
if [ -z "$VERSION" ]; then
return 0
fi
CACHED_RUNNER="/opt/cache/actions-runner/$VERSION"
if [ -d "$CACHED_RUNNER" ];then
echo "$CACHED_RUNNER"
return 0
fi
return 0
}
function downloadAndExtractRunner() {
sendStatus "downloading tools from {{ .DownloadURL }}"
if [ ! -z "{{ .TempDownloadToken }}" ]; then
TEMP_TOKEN="Authorization: Bearer {{ .TempDownloadToken }}"
fi
curl --retry 5 --retry-delay 5 --retry-connrefused --fail -L -H "${TEMP_TOKEN}" -o "/home/{{ .RunnerUsername }}/{{ .FileName }}" "{{ .DownloadURL }}" || fail "failed to download tools"
mkdir -p /home/{{ .RunnerUsername }}/actions-runner || fail "failed to create actions-runner folder"
sendStatus "extracting runner"
tar xf "/home/{{ .RunnerUsername }}/{{ .FileName }}" -C /home/{{ .RunnerUsername }}/actions-runner/ || fail "failed to extract runner"
# chown {{ .RunnerUsername }}:{{ .RunnerGroup }} -R /home/{{ .RunnerUsername }}/actions-runner/ || fail "failed to change owner"
}
TEMP_TOKEN=""
GH_RUNNER_GROUP="{{.GitHubRunnerGroup}}"
# $RUNNER_GROUP_OPT will be added to the config.sh line. If it's empty, nothing happens
# if it holds a value, it will be part of the command.
RUNNER_GROUP_OPT=""
if [ ! -z $GH_RUNNER_GROUP ];then
RUNNER_GROUP_OPT="--runnergroup=$GH_RUNNER_GROUP"
fi
CACHED_RUNNER=$(getCachedToolsPath)
if [ -z "$CACHED_RUNNER" ];then
downloadAndExtractRunner
sendStatus "installing dependencies"
cd /home/{{ .RunnerUsername }}/actions-runner
sudo ./bin/installdependencies.sh || fail "failed to install dependencies"
else
sendStatus "using cached runner found in $CACHED_RUNNER"
sudo cp -a "$CACHED_RUNNER" "/home/{{ .RunnerUsername }}/actions-runner"
sudo chown {{ .RunnerUsername }}:{{ .RunnerGroup }} -R "/home/{{ .RunnerUsername }}/actions-runner" || fail "failed to change owner"
cd /home/{{ .RunnerUsername }}/actions-runner
fi
sendStatus "configuring runner"
set +e
attempt=1
while true; do
ERROUT=$(mktemp)
./config.sh --unattended --url "{{ .RepoURL }}" --token "$GITHUB_TOKEN" $RUNNER_GROUP_OPT --name "{{ .RunnerName }}" --labels "{{ .RunnerLabels }}" --ephemeral 2>$ERROUT
if [ $? -eq 0 ]; then
rm $ERROUT || true
sendStatus "runner successfully configured after $attempt attempt(s)"
break
fi
LAST_ERR=$(cat $ERROUT)
echo "$LAST_ERR"
# if the runner is already configured, remove it and try again. In the past configuring a runner
# managed to register it but timed out later, resulting in an error.
./config.sh remove --token "$GITHUB_TOKEN" || true
if [ $attempt -gt 5 ];then
rm $ERROUT || true
fail "failed to configure runner: $LAST_ERR"
fi
sendStatus "failed to configure runner (attempt $attempt): $LAST_ERR (retrying in 5 seconds)"
attempt=$((attempt+1))
rm $ERROUT || true
sleep 5
done
set -e
sendStatus "installing runner service"
sudo ./svc.sh install {{ .RunnerUsername }} || fail "failed to install service"
if [ -e "/sys/fs/selinux" ];then
sudo chcon -h user_u:object_r:bin_t /home/runner/ || fail "failed to change selinux context"
sudo chcon -R -h {{ .RunnerUsername }}:object_r:bin_t /home/runner/* || fail "failed to change selinux context"
fi
sendStatus "starting service"
sudo ./svc.sh start || fail "failed to start service"
set +e
AGENT_ID=$(grep "agentId" /home/{{ .RunnerUsername }}/actions-runner/.runner | tr -d -c 0-9)
if [ $? -ne 0 ];then
fail "failed to get agent ID"
fi
set -e
success "runner successfully installed" $AGENT_ID
`
var WindowsSetupScriptTemplate = `#ps1_sysnative
Param(
[Parameter(Mandatory=$false)]
[string]$Token="{{.CallbackToken}}"
)
$ErrorActionPreference="Stop"
function Invoke-FastWebRequest {
[CmdletBinding()]
Param(
[Parameter(Mandatory=$True,ValueFromPipeline=$true,Position=0)]
[System.Uri]$Uri,
[Parameter(Position=1)]
[string]$OutFile,
[Hashtable]$Headers=@{},
[switch]$SkipIntegrityCheck=$false
)
PROCESS
{
if(!([System.Management.Automation.PSTypeName]'System.Net.Http.HttpClient').Type)
{
$assembly = [System.Reflection.Assembly]::LoadWithPartialName("System.Net.Http")
}
if(!$OutFile) {
$OutFile = $Uri.PathAndQuery.Substring($Uri.PathAndQuery.LastIndexOf("/") + 1)
if(!$OutFile) {
throw "The ""OutFile"" parameter needs to be specified"
}
}
$fragment = $Uri.Fragment.Trim('#')
if ($fragment) {
$details = $fragment.Split("=")
$algorithm = $details[0]
$hash = $details[1]
}
if (!$SkipIntegrityCheck -and $fragment -and (Test-Path $OutFile)) {
try {
return (Test-FileIntegrity -File $OutFile -Algorithm $algorithm -ExpectedHash $hash)
} catch {
Remove-Item $OutFile
}
}
$client = new-object System.Net.Http.HttpClient
foreach ($k in $Headers.Keys){
$client.DefaultRequestHeaders.Add($k, $Headers[$k])
}
$task = $client.GetStreamAsync($Uri)
$response = $task.Result
if($task.IsFaulted) {
$msg = "Request for URL '{0}' is faulted. Task status: {1}." -f @($Uri, $task.Status)
if($task.Exception) {
$msg += "Exception details: {0}" -f @($task.Exception)
}
Throw $msg
}
$outStream = New-Object IO.FileStream $OutFile, Create, Write, None
try {
$totRead = 0
$buffer = New-Object Byte[] 1MB
while (($read = $response.Read($buffer, 0, $buffer.Length)) -gt 0) {
$totRead += $read
$outStream.Write($buffer, 0, $read);
}
}
finally {
$outStream.Close()
}
if(!$SkipIntegrityCheck -and $fragment) {
Test-FileIntegrity -File $OutFile -Algorithm $algorithm -ExpectedHash $hash
}
}
}
function Import-Certificate() {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
[string]$CertificatePath,
[parameter(Mandatory=$true)]
[System.Security.Cryptography.X509Certificates.StoreLocation]$StoreLocation="LocalMachine",
[parameter(Mandatory=$true)]
[System.Security.Cryptography.X509Certificates.StoreName]$StoreName="TrustedPublisher"
)
PROCESS
{
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store(
$StoreName, $StoreLocation)
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(
$CertificatePath)
$store.Add($cert)
}
}
function Invoke-APICall() {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
[object]$Payload,
[parameter(Mandatory=$true)]
[string]$CallbackURL
)
PROCESS{
Invoke-WebRequest -UseBasicParsing -Method Post -Headers @{"Accept"="application/json"; "Authorization"="Bearer $Token"} -Uri $CallbackURL -Body (ConvertTo-Json $Payload) | Out-Null
}
}
function Update-GarmStatus() {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
[string]$Message,
[parameter(Mandatory=$true)]
[string]$CallbackURL
)
PROCESS{
$body = @{
"status"="installing"
"message"=$Message
}
Invoke-APICall -Payload $body -CallbackURL $CallbackURL | Out-Null
}
}
function Invoke-GarmSuccess() {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
[string]$Message,
[parameter(Mandatory=$true)]
[int64]$AgentID,
[parameter(Mandatory=$true)]
[string]$CallbackURL
)
PROCESS{
$body = @{
"status"="idle"
"message"=$Message
"agent_id"=$AgentID
}
Invoke-APICall -Payload $body -CallbackURL $CallbackURL | Out-Null
}
}
function Invoke-GarmFailure() {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
[string]$Message,
[parameter(Mandatory=$true)]
[string]$CallbackURL
)
PROCESS{
$body = @{
"status"="failed"
"message"=$Message
}
Invoke-APICall -Payload $body -CallbackURL $CallbackURL | Out-Null
Throw $Message
}
}
$PEMData = @"
{{.CABundle}}
"@
$GHRunnerGroup = "{{.GitHubRunnerGroup}}"
function Install-Runner() {
$CallbackURL="{{.CallbackURL}}"
if (!$CallbackURL.EndsWith("/status")) {
$CallbackURL = "$CallbackURL/status"
}
if ($Token.Length -eq 0) {
Throw "missing callback authentication token"
}
try {
$MetadataURL="{{.MetadataURL}}"
$DownloadURL="{{.DownloadURL}}"
if($MetadataURL -eq ""){
Throw "missing metadata URL"
}
if($PEMData.Trim().Length -gt 0){
Set-Content $env:TMP\garm-ca.pem $PEMData
Import-Certificate -CertificatePath $env:TMP\garm-ca.pem
}
$GithubRegistrationToken = Invoke-WebRequest -UseBasicParsing -Headers @{"Accept"="application/json"; "Authorization"="Bearer $Token"} -Uri $MetadataURL/runner-registration-token/
Update-GarmStatus -CallbackURL $CallbackURL -Message "downloading tools from $DownloadURL"
$downloadToken="{{.TempDownloadToken}}"
$DownloadTokenHeaders=@{}
if ($downloadToken.Length -gt 0) {
$DownloadTokenHeaders=@{
"Authorization"="Bearer $downloadToken"
}
}
$downloadPath = Join-Path $env:TMP {{.FileName}}
Invoke-FastWebRequest -Uri $DownloadURL -OutFile $downloadPath -Headers $DownloadTokenHeaders
$runnerDir = "C:\runner"
mkdir $runnerDir
Update-GarmStatus -CallbackURL $CallbackURL -Message "extracting runner"
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory($downloadPath, "$runnerDir")
$runnerGroupOpt = ""
if ($GHRunnerGroup.Length -gt 0){
$runnerGroupOpt = "--runnergroup $GHRunnerGroup"
}
Update-GarmStatus -CallbackURL $CallbackURL -Message "configuring and starting runner"
cd $runnerDir
./config.cmd --unattended --url "{{ .RepoURL }}" --token $GithubRegistrationToken $runnerGroupOpt --name "{{ .RunnerName }}" --labels "{{ .RunnerLabels }}" --ephemeral --runasservice
$agentInfoFile = Join-Path $runnerDir ".runner"
$agentInfo = ConvertFrom-Json (gc -raw $agentInfoFile)
Invoke-GarmSuccess -CallbackURL $CallbackURL -Message "runner successfully installed" -AgentID $agentInfo.agentId
} catch {
Invoke-GarmFailure -CallbackURL $CallbackURL -Message $_
}
}
Install-Runner
`
type InstallRunnerParams struct {
FileName string
DownloadURL string
RunnerUsername string
RunnerGroup string
RepoURL string
MetadataURL string
RunnerName string
RunnerLabels string
CallbackURL string
CallbackToken string
TempDownloadToken string
CABundle string
GitHubRunnerGroup string
}
func InstallRunnerScript(installParams InstallRunnerParams, osType params.OSType) ([]byte, error) {
var tpl string
switch osType {
case params.Linux:
tpl = CloudConfigTemplate
case params.Windows:
tpl = WindowsSetupScriptTemplate
default:
return nil, fmt.Errorf("unsupported os type: %s", osType)
}
t, err := template.New("").Parse(tpl)
if err != nil {
return nil, errors.Wrap(err, "parsing template")
}
var buf bytes.Buffer
if err := t.Execute(&buf, installParams); err != nil {
return nil, errors.Wrap(err, "rendering template")
}
return buf.Bytes(), nil
}

View file

@ -0,0 +1,17 @@
package defaults
const (
// DefaultUser is the default username that should exist on the instances.
DefaultUser = "runner"
// DefaultUserShell is the shell for the default user.
DefaultUserShell = "/bin/bash"
)
var (
// DefaultUserGroups are the groups the default user will be part of.
DefaultUserGroups = []string{
"sudo", "adm", "cdrom", "dialout",
"dip", "video", "plugdev", "netdev",
"docker", "lxd",
}
)

View file

@ -0,0 +1,13 @@
package execution
type ExecutionCommand string
const (
CreateInstanceCommand ExecutionCommand = "CreateInstance"
DeleteInstanceCommand ExecutionCommand = "DeleteInstance"
GetInstanceCommand ExecutionCommand = "GetInstance"
ListInstancesCommand ExecutionCommand = "ListInstances"
StartInstanceCommand ExecutionCommand = "StartInstance"
StopInstanceCommand ExecutionCommand = "StopInstance"
RemoveAllInstancesCommand ExecutionCommand = "RemoveAllInstances"
)

View file

@ -0,0 +1,172 @@
package execution
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"github.com/cloudbase/garm-provider-common/params"
"github.com/mattn/go-isatty"
)
const (
// ExitCodeNotFound is an exit code that indicates a Not Found error
ExitCodeNotFound int = 30
// ExitCodeDuplicate is an exit code that indicates a duplicate error
ExitCodeDuplicate int = 31
)
func GetEnvironment() (Environment, error) {
env := Environment{
Command: ExecutionCommand(os.Getenv("GARM_COMMAND")),
ControllerID: os.Getenv("GARM_CONTROLLER_ID"),
PoolID: os.Getenv("GARM_POOL_ID"),
ProviderConfigFile: os.Getenv("GARM_PROVIDER_CONFIG_FILE"),
InstanceID: os.Getenv("GARM_INSTANCE_ID"),
}
// If this is a CreateInstance command, we need to get the bootstrap params
// from stdin
if env.Command == CreateInstanceCommand {
if isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd()) {
return Environment{}, fmt.Errorf("%s requires data passed into stdin", CreateInstanceCommand)
}
var data bytes.Buffer
if _, err := io.Copy(&data, os.Stdin); err != nil {
return Environment{}, fmt.Errorf("failed to copy bootstrap params")
}
if data.Len() == 0 {
return Environment{}, fmt.Errorf("%s requires data passed into stdin", CreateInstanceCommand)
}
var bootstrapParams params.BootstrapInstance
if err := json.Unmarshal(data.Bytes(), &bootstrapParams); err != nil {
return Environment{}, fmt.Errorf("failed to decode instance params: %w", err)
}
env.BootstrapParams = bootstrapParams
}
if err := env.Validate(); err != nil {
return Environment{}, fmt.Errorf("failed to validate execution environment: %w", err)
}
return env, nil
}
type Environment struct {
Command ExecutionCommand
ControllerID string
PoolID string
ProviderConfigFile string
InstanceID string
BootstrapParams params.BootstrapInstance
}
func (e Environment) Validate() error {
if e.Command == "" {
return fmt.Errorf("missing GARM_COMMAND")
}
if e.ProviderConfigFile == "" {
return fmt.Errorf("missing GARM_PROVIDER_CONFIG_FILE")
}
if _, err := os.Lstat(e.ProviderConfigFile); err != nil {
return fmt.Errorf("error accessing config file: %w", err)
}
if e.ControllerID == "" {
return fmt.Errorf("missing GARM_CONTROLLER_ID")
}
switch e.Command {
case CreateInstanceCommand:
if e.BootstrapParams.Name == "" {
return fmt.Errorf("missing bootstrap params")
}
if e.ControllerID == "" {
return fmt.Errorf("missing controller ID")
}
if e.PoolID == "" {
return fmt.Errorf("missing pool ID")
}
case DeleteInstanceCommand, GetInstanceCommand,
StartInstanceCommand, StopInstanceCommand:
if e.InstanceID == "" {
return fmt.Errorf("missing instance ID")
}
case ListInstancesCommand:
if e.PoolID == "" {
return fmt.Errorf("missing pool ID")
}
case RemoveAllInstancesCommand:
if e.ControllerID == "" {
return fmt.Errorf("missing controller ID")
}
default:
return fmt.Errorf("unknown GARM_COMMAND: %s", e.Command)
}
return nil
}
func Run(ctx context.Context, provider ExternalProvider, env Environment) (string, error) {
var ret string
switch env.Command {
case CreateInstanceCommand:
instance, err := provider.CreateInstance(ctx, env.BootstrapParams)
if err != nil {
return "", fmt.Errorf("failed to create instance in provider: %w", err)
}
asJs, err := json.Marshal(instance)
if err != nil {
return "", fmt.Errorf("failed to marshal response: %w", err)
}
ret = string(asJs)
case GetInstanceCommand:
instance, err := provider.GetInstance(ctx, env.InstanceID)
if err != nil {
return "", fmt.Errorf("failed to get instance from provider: %w", err)
}
asJs, err := json.Marshal(instance)
if err != nil {
return "", fmt.Errorf("failed to marshal response: %w", err)
}
ret = string(asJs)
case ListInstancesCommand:
instances, err := provider.ListInstances(ctx, env.PoolID)
if err != nil {
return "", fmt.Errorf("failed to list instances from provider: %w", err)
}
asJs, err := json.Marshal(instances)
if err != nil {
return "", fmt.Errorf("failed to marshal response: %w", err)
}
ret = string(asJs)
case DeleteInstanceCommand:
if err := provider.DeleteInstance(ctx, env.InstanceID); err != nil {
return "", fmt.Errorf("failed to delete instance from provider: %w", err)
}
case RemoveAllInstancesCommand:
if err := provider.RemoveAllInstances(ctx); err != nil {
return "", fmt.Errorf("failed to destroy environment: %w", err)
}
case StartInstanceCommand:
if err := provider.Start(ctx, env.InstanceID); err != nil {
return "", fmt.Errorf("failed to start instance: %w", err)
}
case StopInstanceCommand:
if err := provider.Stop(ctx, env.InstanceID, true); err != nil {
return "", fmt.Errorf("failed to stop instance: %w", err)
}
default:
return "", fmt.Errorf("invalid command: %s", env.Command)
}
return ret, nil
}

View file

@ -0,0 +1,27 @@
package execution
import (
"context"
"github.com/cloudbase/garm-provider-common/params"
)
// ExternalProvider defines an interface that external providers need to implement.
// This is very similar to the common.Provider interface, and was redefined here to
// decouple it, in case it may diverge from native providers.
type ExternalProvider interface {
// CreateInstance creates a new compute instance in the provider.
CreateInstance(ctx context.Context, bootstrapParams params.BootstrapInstance) (params.ProviderInstance, error)
// Delete instance will delete the instance in a provider.
DeleteInstance(ctx context.Context, instance string) error
// GetInstance will return details about one instance.
GetInstance(ctx context.Context, instance string) (params.ProviderInstance, error)
// ListInstances will list all instances for a provider.
ListInstances(ctx context.Context, poolID string) ([]params.ProviderInstance, error)
// RemoveAllInstances will remove all instances created by this provider.
RemoveAllInstances(ctx context.Context) error
// Stop shuts down the instance.
Stop(ctx context.Context, instance string, force bool) error
// Start boots up an instance.
Start(ctx context.Context, instance string) error
}

View file

@ -0,0 +1,144 @@
package params
import (
"encoding/json"
"github.com/google/go-github/v48/github"
)
type (
AddressType string
InstanceStatus string
OSType string
OSArch string
)
const (
Windows OSType = "windows"
Linux OSType = "linux"
Unknown OSType = "unknown"
)
const (
Amd64 OSArch = "amd64"
I386 OSArch = "i386"
Arm64 OSArch = "arm64"
Arm OSArch = "arm"
)
const (
InstanceRunning InstanceStatus = "running"
InstanceStopped InstanceStatus = "stopped"
InstanceError InstanceStatus = "error"
InstancePendingDelete InstanceStatus = "pending_delete"
InstanceDeleting InstanceStatus = "deleting"
InstancePendingCreate InstanceStatus = "pending_create"
InstanceCreating InstanceStatus = "creating"
InstanceStatusUnknown InstanceStatus = "unknown"
)
const (
PublicAddress AddressType = "public"
PrivateAddress AddressType = "private"
)
type UserDataOptions struct {
DisableUpdatesOnBoot bool `json:"disable_updates_on_boot"`
ExtraPackages []string `json:"extra_packages"`
}
type BootstrapInstance struct {
Name string `json:"name"`
Tools []*github.RunnerApplicationDownload `json:"tools"`
// RepoURL is the URL the github runner agent needs to configure itself.
RepoURL string `json:"repo_url"`
// CallbackUrl is the URL where the instance can send a post, signaling
// progress or status.
CallbackURL string `json:"callback-url"`
// MetadataURL is the URL where instances can fetch information needed to set themselves up.
MetadataURL string `json:"metadata-url"`
// InstanceToken is the token that needs to be set by the instance in the headers
// in order to send updated back to the garm via CallbackURL.
InstanceToken string `json:"instance-token"`
// SSHKeys are the ssh public keys we may want to inject inside the runners, if the
// provider supports it.
SSHKeys []string `json:"ssh-keys"`
// ExtraSpecs is an opaque raw json that gets sent to the provider
// as part of the bootstrap params for instances. It can contain
// any kind of data needed by providers. The contents of this field means
// nothing to garm itself. We don't act on the information in this field at
// all. We only validate that it's a proper json.
ExtraSpecs json.RawMessage `json:"extra_specs,omitempty"`
// GitHubRunnerGroup is the github runner group in which the newly installed runner
// should be added to. The runner group must be created by someone with access to the
// enterprise.
GitHubRunnerGroup string `json:"github-runner-group"`
// CACertBundle is a CA certificate bundle which will be sent to instances and which
// will tipically be installed as a system wide trusted root CA. by either cloud-init
// or whatever mechanism the provider will use to set up the runner.
CACertBundle []byte `json:"ca-cert-bundle"`
// OSArch is the target OS CPU architecture of the runner.
OSArch OSArch `json:"arch"`
// OSType is the target OS platform of the runner (windows, linux).
OSType OSType `json:"os_type"`
// Flavor is the platform specific abstraction that defines what resources will be allocated
// to the runner (CPU, RAM, disk space, etc). This field is meaningful to the provider which
// handles the actual creation.
Flavor string `json:"flavor"`
// Image is the platform specific identifier of the operating system template that will be used
// to spin up a new machine.
Image string `json:"image"`
// Labels are a list of github runner labels that will be added to the runner.
Labels []string `json:"labels"`
// PoolID is the ID of the garm pool to which this runner belongs.
PoolID string `json:"pool_id"`
// UserDataOptions are the options for the user data generation.
UserDataOptions UserDataOptions `json:"user_data_options"`
}
type Address struct {
Address string `json:"address"`
Type AddressType `json:"type"`
}
type ProviderInstance struct {
// PeoviderID is the unique ID the provider associated
// with the compute instance. We use this to identify the
// instance in the provider.
ProviderID string `json:"provider_id,omitempty"`
// Name is the name associated with an instance. Depending on
// the provider, this may or may not be useful in the context of
// the provider, but we can use it internally to identify the
// instance.
Name string `json:"name,omitempty"`
// OSType is the operating system type. For now, only Linux and
// Windows are supported.
OSType OSType `json:"os_type,omitempty"`
// OSName is the name of the OS. Eg: ubuntu, centos, etc.
OSName string `json:"os_name,omitempty"`
// OSVersion is the version of the operating system.
OSVersion string `json:"os_version,omitempty"`
// OSArch is the operating system architecture.
OSArch OSArch `json:"os_arch,omitempty"`
// Addresses is a list of IP addresses the provider reports
// for this instance.
Addresses []Address `json:"addresses,omitempty"`
// Status is the status of the instance inside the provider (eg: running, stopped, etc)
Status InstanceStatus `json:"status,omitempty"`
}