Version provider interface

This commit is contained in:
Fabian Fulga 2024-07-09 12:49:29 +03:00
parent deb30e1d25
commit 03f280da59
17 changed files with 2168 additions and 342 deletions

View file

@ -29,6 +29,10 @@ import (
// whatever programming language you wish, while still remaining compatible
// with garm.
type External struct {
// InterfaceVersion is the version of the interface that the external
// provider implements. This is used to ensure compatibility between
// the external provider and garm.
InterfaceVersion string `toml:"interface_version" json:"interface-version"`
// ConfigFile is the path on disk to a file which will be passed to
// the external binary as an environment variable: GARM_PROVIDER_CONFIG
// You can use this file for any configuration you need to do for the

View file

@ -9,6 +9,7 @@ import (
mock "github.com/stretchr/testify/mock"
params "github.com/cloudbase/garm/params"
"github.com/cloudbase/garm/runner/common"
)
// Provider is an autogenerated mock type for the Provider type
@ -35,7 +36,7 @@ func (_m *Provider) AsParams() params.Provider {
}
// CreateInstance provides a mock function with given fields: ctx, bootstrapParams
func (_m *Provider) CreateInstance(ctx context.Context, bootstrapParams garm_provider_commonparams.BootstrapInstance) (garm_provider_commonparams.ProviderInstance, error) {
func (_m *Provider) CreateInstance(ctx context.Context, bootstrapParams garm_provider_commonparams.BootstrapInstance, createInstanceParams common.CreateInstanceParams) (garm_provider_commonparams.ProviderInstance, error) {
ret := _m.Called(ctx, bootstrapParams)
if len(ret) == 0 {
@ -63,7 +64,7 @@ func (_m *Provider) CreateInstance(ctx context.Context, bootstrapParams garm_pro
}
// DeleteInstance provides a mock function with given fields: ctx, instance
func (_m *Provider) DeleteInstance(ctx context.Context, instance string) error {
func (_m *Provider) DeleteInstance(ctx context.Context, instance string, deleteInstanceParams common.DeleteInstanceParams) error {
ret := _m.Called(ctx, instance)
if len(ret) == 0 {
@ -99,7 +100,7 @@ func (_m *Provider) DisableJITConfig() bool {
}
// GetInstance provides a mock function with given fields: ctx, instance
func (_m *Provider) GetInstance(ctx context.Context, instance string) (garm_provider_commonparams.ProviderInstance, error) {
func (_m *Provider) GetInstance(ctx context.Context, instance string, getInstanceParams common.GetInstanceParams) (garm_provider_commonparams.ProviderInstance, error) {
ret := _m.Called(ctx, instance)
if len(ret) == 0 {
@ -127,7 +128,7 @@ func (_m *Provider) GetInstance(ctx context.Context, instance string) (garm_prov
}
// ListInstances provides a mock function with given fields: ctx, poolID
func (_m *Provider) ListInstances(ctx context.Context, poolID string) ([]garm_provider_commonparams.ProviderInstance, error) {
func (_m *Provider) ListInstances(ctx context.Context, poolID string, listInstancesParams common.ListInstancesParams) ([]garm_provider_commonparams.ProviderInstance, error) {
ret := _m.Called(ctx, poolID)
if len(ret) == 0 {
@ -157,7 +158,7 @@ func (_m *Provider) ListInstances(ctx context.Context, poolID string) ([]garm_pr
}
// RemoveAllInstances provides a mock function with given fields: ctx
func (_m *Provider) RemoveAllInstances(ctx context.Context) error {
func (_m *Provider) RemoveAllInstances(ctx context.Context, removeAllInstances common.RemoveAllInstancesParams) error {
ret := _m.Called(ctx)
if len(ret) == 0 {
@ -175,7 +176,7 @@ func (_m *Provider) RemoveAllInstances(ctx context.Context) error {
}
// Start provides a mock function with given fields: ctx, instance
func (_m *Provider) Start(ctx context.Context, instance string) error {
func (_m *Provider) Start(ctx context.Context, instance string, startParams common.StartParams) error {
ret := _m.Called(ctx, instance)
if len(ret) == 0 {
@ -193,7 +194,7 @@ func (_m *Provider) Start(ctx context.Context, instance string) error {
}
// Stop provides a mock function with given fields: ctx, instance
func (_m *Provider) Stop(ctx context.Context, instance string) error {
func (_m *Provider) Stop(ctx context.Context, instance string, stopParams common.StopParams) error {
ret := _m.Called(ctx, instance)
if len(ret) == 0 {

88
runner/common/params.go Normal file
View file

@ -0,0 +1,88 @@
// 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 common
import "github.com/cloudbase/garm/params"
// Constants used for the provider interface version.
const (
Version010 = "v0.1.0"
Version011 = "v0.1.1"
)
// Each struct is a wrapper for the actual parameters struct for a specific version.
// Version 0.1.0 doesn't have any specific parameters, so there is no need for a struct for it.
type CreateInstanceParams struct {
CreateInstanceV011 CreateInstanceV011Params
}
type DeleteInstanceParams struct {
DeleteInstanceV011 DeleteInstanceV011Params
}
type GetInstanceParams struct {
GetInstanceV011 GetInstanceV011Params
}
type ListInstancesParams struct {
ListInstancesV011 ListInstancesV011Params
}
type RemoveAllInstancesParams struct {
RemoveAllInstancesV011 RemoveAllInstancesV011Params
}
type StopParams struct {
StopV011 StopV011Params
}
type StartParams struct {
StartV011 StartV011Params
}
// Struct for the base provider parameters.
type ProviderBaseParams struct {
PoolInfo params.Pool
ControllerInfo params.ControllerInfo
}
// Structs for version v0.1.1.
type CreateInstanceV011Params struct {
ProviderBaseParams
}
type DeleteInstanceV011Params struct {
ProviderBaseParams
}
type GetInstanceV011Params struct {
ProviderBaseParams
}
type ListInstancesV011Params struct {
ProviderBaseParams
}
type RemoveAllInstancesV011Params struct {
ProviderBaseParams
}
type StopV011Params struct {
ProviderBaseParams
}
type StartV011Params struct {
ProviderBaseParams
}

View file

@ -24,19 +24,19 @@ import (
//go:generate mockery --all
type Provider interface {
// CreateInstance creates a new compute instance in the provider.
CreateInstance(ctx context.Context, bootstrapParams commonParams.BootstrapInstance) (commonParams.ProviderInstance, error)
CreateInstance(ctx context.Context, bootstrapParams commonParams.BootstrapInstance, createInstanceParams CreateInstanceParams) (commonParams.ProviderInstance, error)
// Delete instance will delete the instance in a provider.
DeleteInstance(ctx context.Context, instance string) error
DeleteInstance(ctx context.Context, instance string, deleteInstanceParams DeleteInstanceParams) error
// GetInstance will return details about one instance.
GetInstance(ctx context.Context, instance string) (commonParams.ProviderInstance, error)
GetInstance(ctx context.Context, instance string, getInstanceParams GetInstanceParams) (commonParams.ProviderInstance, error)
// ListInstances will list all instances for a provider.
ListInstances(ctx context.Context, poolID string) ([]commonParams.ProviderInstance, error)
ListInstances(ctx context.Context, poolID string, listInstancesParams ListInstancesParams) ([]commonParams.ProviderInstance, error)
// RemoveAllInstances will remove all instances created by this provider.
RemoveAllInstances(ctx context.Context) error
RemoveAllInstances(ctx context.Context, removeAllInstancesParams RemoveAllInstancesParams) error
// Stop shuts down the instance.
Stop(ctx context.Context, instance string) error
Stop(ctx context.Context, instance string, stopParams StopParams) error
// Start boots up an instance.
Start(ctx context.Context, instance string) error
Start(ctx context.Context, instance string, startParams StartParams) error
// DisableJITConfig tells us if the provider explicitly disables JIT configuration and
// forces runner registration tokens to be used. This may happen if a provider has not yet
// been updated to support JIT configuration.

View file

@ -589,7 +589,15 @@ func (r *basePoolManager) cleanupOrphanedGithubRunners(runners []*github.Runner)
slog.DebugContext(
r.ctx, "updating instances cache for pool",
"pool_id", pool.ID)
poolInstances, err = provider.ListInstances(r.ctx, pool.ID)
listInstancesParams := common.ListInstancesParams{
ListInstancesV011: common.ListInstancesV011Params{
ProviderBaseParams: common.ProviderBaseParams{
PoolInfo: pool,
ControllerInfo: r.controllerInfo,
},
},
}
poolInstances, err = provider.ListInstances(r.ctx, pool.ID, listInstancesParams)
if err != nil {
return errors.Wrapf(err, "fetching instances for pool %s", pool.ID)
}
@ -654,7 +662,15 @@ func (r *basePoolManager) cleanupOrphanedGithubRunners(runners []*github.Runner)
r.ctx, "instance was found in stopped state; starting",
"runner_name", dbInstance.Name)
if err := provider.Start(r.ctx, dbInstance.ProviderID); err != nil {
startParams := common.StartParams{
StartV011: common.StartV011Params{
ProviderBaseParams: common.ProviderBaseParams{
PoolInfo: pool,
ControllerInfo: r.controllerInfo,
},
},
}
if err := provider.Start(r.ctx, dbInstance.ProviderID, startParams); err != nil {
return errors.Wrapf(err, "starting instance %s", dbInstance.ProviderID)
}
return nil
@ -870,7 +886,15 @@ func (r *basePoolManager) addInstanceToProvider(instance params.Instance) error
defer func() {
if instanceIDToDelete != "" {
if err := provider.DeleteInstance(r.ctx, instanceIDToDelete); err != nil {
deleteInstanceParams := common.DeleteInstanceParams{
DeleteInstanceV011: common.DeleteInstanceV011Params{
ProviderBaseParams: common.ProviderBaseParams{
PoolInfo: pool,
ControllerInfo: r.controllerInfo,
},
},
}
if err := provider.DeleteInstance(r.ctx, instanceIDToDelete, deleteInstanceParams); err != nil {
if !errors.Is(err, runnerErrors.ErrNotFound) {
slog.With(slog.Any("error", err)).ErrorContext(
r.ctx, "failed to cleanup instance",
@ -880,7 +904,15 @@ func (r *basePoolManager) addInstanceToProvider(instance params.Instance) error
}
}()
providerInstance, err := provider.CreateInstance(r.ctx, bootstrapArgs)
createInstanceParams := common.CreateInstanceParams{
CreateInstanceV011: common.CreateInstanceV011Params{
ProviderBaseParams: common.ProviderBaseParams{
PoolInfo: pool,
ControllerInfo: r.controllerInfo,
},
},
}
providerInstance, err := provider.CreateInstance(r.ctx, bootstrapArgs, createInstanceParams)
if err != nil {
instanceIDToDelete = instance.Name
return errors.Wrap(err, "creating instance")
@ -1316,7 +1348,15 @@ func (r *basePoolManager) deleteInstanceFromProvider(ctx context.Context, instan
"runner_name", instance.Name,
"provider_id", identifier)
if err := provider.DeleteInstance(ctx, identifier); err != nil {
deleteInstanceParams := common.DeleteInstanceParams{
DeleteInstanceV011: common.DeleteInstanceV011Params{
ProviderBaseParams: common.ProviderBaseParams{
PoolInfo: pool,
ControllerInfo: r.controllerInfo,
},
},
}
if err := provider.DeleteInstance(ctx, identifier, deleteInstanceParams); err != nil {
return errors.Wrap(err, "removing instance")
}

View file

@ -2,334 +2,22 @@ package external
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os/exec"
"github.com/pkg/errors"
garmErrors "github.com/cloudbase/garm-provider-common/errors"
"github.com/cloudbase/garm-provider-common/execution"
commonParams "github.com/cloudbase/garm-provider-common/params"
garmExec "github.com/cloudbase/garm-provider-common/util/exec"
"github.com/cloudbase/garm/config"
"github.com/cloudbase/garm/metrics"
"github.com/cloudbase/garm/params"
"github.com/cloudbase/garm/runner/common"
v010 "github.com/cloudbase/garm/runner/providers/v0.1.0"
v011 "github.com/cloudbase/garm/runner/providers/v0.1.1"
)
var _ common.Provider = (*external)(nil)
// NewProvider selects the provider based on the interface version
func NewProvider(ctx context.Context, cfg *config.Provider, controllerID string) (common.Provider, error) {
if cfg.ProviderType != params.ExternalProvider {
return nil, garmErrors.NewBadRequestError("invalid provider config")
}
execPath, err := cfg.External.ExecutablePath()
if err != nil {
return nil, errors.Wrap(err, "fetching executable path")
}
envVars := cfg.External.GetEnvironmentVariables()
return &external{
ctx: ctx,
controllerID: controllerID,
cfg: cfg,
execPath: execPath,
environmentVariables: envVars,
}, nil
}
type external struct {
ctx context.Context
controllerID string
cfg *config.Provider
execPath string
environmentVariables []string
}
func (e *external) validateResult(inst commonParams.ProviderInstance) error {
if inst.ProviderID == "" {
return garmErrors.NewProviderError("missing provider ID")
}
if inst.Name == "" {
return garmErrors.NewProviderError("missing instance name")
}
if !IsValidProviderStatus(inst.Status) {
return garmErrors.NewProviderError("invalid status returned (%s)", inst.Status)
}
return nil
}
// CreateInstance creates a new compute instance in the provider.
func (e *external) CreateInstance(ctx context.Context, bootstrapParams commonParams.BootstrapInstance) (commonParams.ProviderInstance, error) {
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.CreateInstanceCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_POOL_ID=%s", bootstrapParams.PoolID),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
}
asEnv = append(asEnv, e.environmentVariables...)
asJs, err := json.Marshal(bootstrapParams)
if err != nil {
return commonParams.ProviderInstance{}, errors.Wrap(err, "serializing bootstrap params")
}
metrics.InstanceOperationCount.WithLabelValues(
"CreateInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
out, err := garmExec.Exec(ctx, e.execPath, asJs, asEnv)
if err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"CreateInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
var param commonParams.ProviderInstance
if err := json.Unmarshal(out, &param); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"CreateInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to decode response from binary: %s", err)
}
if err := e.validateResult(param); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"CreateInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to validate result: %s", err)
}
retAsJs, _ := json.MarshalIndent(param, "", " ")
slog.DebugContext(
ctx, "provider returned",
"output", string(retAsJs))
return param, nil
}
// Delete instance will delete the instance in a provider.
func (e *external) DeleteInstance(ctx context.Context, instance string) error {
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.DeleteInstanceCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_INSTANCE_ID=%s", instance),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
}
asEnv = append(asEnv, e.environmentVariables...)
metrics.InstanceOperationCount.WithLabelValues(
"DeleteInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
_, err := garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err != nil {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != execution.ExitCodeNotFound {
metrics.InstanceOperationFailedCount.WithLabelValues(
"DeleteInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
}
return nil
}
// GetInstance will return details about one instance.
func (e *external) GetInstance(ctx context.Context, instance string) (commonParams.ProviderInstance, error) {
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.GetInstanceCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_INSTANCE_ID=%s", instance),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
}
asEnv = append(asEnv, e.environmentVariables...)
// nolint:golangci-lint,godox
// TODO(gabriel-samfira): handle error types. Of particular interest is to
// know when the error is ErrNotFound.
metrics.InstanceOperationCount.WithLabelValues(
"GetInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
out, err := garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"GetInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
var param commonParams.ProviderInstance
if err := json.Unmarshal(out, &param); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"GetInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to decode response from binary: %s", err)
}
if err := e.validateResult(param); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"GetInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to validate result: %s", err)
}
return param, nil
}
// ListInstances will list all instances for a provider.
func (e *external) ListInstances(ctx context.Context, poolID string) ([]commonParams.ProviderInstance, error) {
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.ListInstancesCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_POOL_ID=%s", poolID),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
}
asEnv = append(asEnv, e.environmentVariables...)
metrics.InstanceOperationCount.WithLabelValues(
"ListInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
out, err := garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"ListInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
return []commonParams.ProviderInstance{}, garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
var param []commonParams.ProviderInstance
if err := json.Unmarshal(out, &param); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"ListInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
return []commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to decode response from binary: %s", err)
}
ret := make([]commonParams.ProviderInstance, len(param))
for idx, inst := range param {
if err := e.validateResult(inst); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"ListInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
return []commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to validate result: %s", err)
}
ret[idx] = inst
}
return ret, nil
}
// RemoveAllInstances will remove all instances created by this provider.
func (e *external) RemoveAllInstances(ctx context.Context) error {
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.RemoveAllInstancesCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
}
asEnv = append(asEnv, e.environmentVariables...)
metrics.InstanceOperationCount.WithLabelValues(
"RemoveAllInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
_, err := garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"RemoveAllInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
return garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
return nil
}
// Stop shuts down the instance.
func (e *external) Stop(ctx context.Context, instance string) error {
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.StopInstanceCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_INSTANCE_ID=%s", instance),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
}
asEnv = append(asEnv, e.environmentVariables...)
metrics.InstanceOperationCount.WithLabelValues(
"Stop", // label: operation
e.cfg.Name, // label: provider
).Inc()
_, err := garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"Stop", // label: operation
e.cfg.Name, // label: provider
).Inc()
return garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
return nil
}
// Start boots up an instance.
func (e *external) Start(ctx context.Context, instance string) error {
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.StartInstanceCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_INSTANCE_ID=%s", instance),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
}
asEnv = append(asEnv, e.environmentVariables...)
metrics.InstanceOperationCount.WithLabelValues(
"Start", // label: operation
e.cfg.Name, // label: provider
).Inc()
_, err := garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"Start", // label: operation
e.cfg.Name, // label: provider
).Inc()
return garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
return nil
}
func (e *external) AsParams() params.Provider {
return params.Provider{
Name: e.cfg.Name,
Description: e.cfg.Description,
ProviderType: e.cfg.ProviderType,
switch cfg.External.InterfaceVersion {
case common.Version010, "":
return v010.NewProvider(ctx, cfg, controllerID)
case common.Version011:
return v011.NewProvider(ctx, cfg, controllerID)
default:
return nil, fmt.Errorf("unsupported interface version: %s", cfg.External.InterfaceVersion)
}
}
// DisableJITConfig tells us if the provider explicitly disables JIT configuration and
// forces runner registration tokens to be used. This may happen if a provider has not yet
// been updated to support JIT configuration.
func (e *external) DisableJITConfig() bool {
if e.cfg == nil {
return false
}
return e.cfg.DisableJITConfig
}

View file

@ -1,4 +1,4 @@
package external
package util
import (
commonParams "github.com/cloudbase/garm-provider-common/params"

View file

@ -0,0 +1,342 @@
package v010
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os/exec"
"github.com/pkg/errors"
garmErrors "github.com/cloudbase/garm-provider-common/errors"
execution "github.com/cloudbase/garm-provider-common/execution/v0.1.0"
commonParams "github.com/cloudbase/garm-provider-common/params"
garmExec "github.com/cloudbase/garm-provider-common/util/exec"
"github.com/cloudbase/garm/config"
"github.com/cloudbase/garm/metrics"
"github.com/cloudbase/garm/params"
"github.com/cloudbase/garm/runner/common"
"github.com/cloudbase/garm/runner/providers/util"
)
var _ common.Provider = (*external)(nil)
// NewProvider creates a legacy external provider.
func NewProvider(ctx context.Context, cfg *config.Provider, controllerID string) (common.Provider, error) {
if cfg.ProviderType != params.ExternalProvider {
return nil, garmErrors.NewBadRequestError("invalid provider config")
}
execPath, err := cfg.External.ExecutablePath()
if err != nil {
return nil, errors.Wrap(err, "fetching executable path")
}
// Set GARM_INTERFACE_VERSION to the version of the interface that the external
// provider implements. This is used to ensure compatibility between the external
// provider and garm
envVars := cfg.External.GetEnvironmentVariables()
envVars = append(envVars, fmt.Sprintf("GARM_INTERFACE_VERSION=%s", common.Version010))
return &external{
ctx: ctx,
controllerID: controllerID,
cfg: cfg,
execPath: execPath,
environmentVariables: envVars,
}, nil
}
type external struct {
ctx context.Context
controllerID string
cfg *config.Provider
execPath string
environmentVariables []string
}
func (e *external) validateResult(inst commonParams.ProviderInstance) error {
if inst.ProviderID == "" {
return garmErrors.NewProviderError("missing provider ID")
}
if inst.Name == "" {
return garmErrors.NewProviderError("missing instance name")
}
if !util.IsValidProviderStatus(inst.Status) {
return garmErrors.NewProviderError("invalid status returned (%s)", inst.Status)
}
return nil
}
// CreateInstance creates a new compute instance in the provider.
func (e *external) CreateInstance(ctx context.Context, bootstrapParams commonParams.BootstrapInstance, _ common.CreateInstanceParams) (commonParams.ProviderInstance, error) {
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.CreateInstanceCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_POOL_ID=%s", bootstrapParams.PoolID),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
}
asEnv = append(asEnv, e.environmentVariables...)
asJs, err := json.Marshal(bootstrapParams)
if err != nil {
return commonParams.ProviderInstance{}, errors.Wrap(err, "serializing bootstrap params")
}
metrics.InstanceOperationCount.WithLabelValues(
"CreateInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
out, err := garmExec.Exec(ctx, e.execPath, asJs, asEnv)
if err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"CreateInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
var param commonParams.ProviderInstance
if err := json.Unmarshal(out, &param); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"CreateInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to decode response from binary: %s", err)
}
if err := e.validateResult(param); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"CreateInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to validate result: %s", err)
}
retAsJs, _ := json.MarshalIndent(param, "", " ")
slog.DebugContext(
ctx, "provider returned",
"output", string(retAsJs))
return param, nil
}
// Delete instance will delete the instance in a provider.
func (e *external) DeleteInstance(ctx context.Context, instance string, _ common.DeleteInstanceParams) error {
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.DeleteInstanceCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_INSTANCE_ID=%s", instance),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
}
asEnv = append(asEnv, e.environmentVariables...)
metrics.InstanceOperationCount.WithLabelValues(
"DeleteInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
_, err := garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err != nil {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != execution.ExitCodeNotFound {
metrics.InstanceOperationFailedCount.WithLabelValues(
"DeleteInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
}
return nil
}
// GetInstance will return details about one instance.
func (e *external) GetInstance(ctx context.Context, instance string, _ common.GetInstanceParams) (commonParams.ProviderInstance, error) {
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.GetInstanceCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_INSTANCE_ID=%s", instance),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
}
asEnv = append(asEnv, e.environmentVariables...)
// nolint:golangci-lint,godox
// TODO(gabriel-samfira): handle error types. Of particular interest is to
// know when the error is ErrNotFound.
metrics.InstanceOperationCount.WithLabelValues(
"GetInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
out, err := garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"GetInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
var param commonParams.ProviderInstance
if err := json.Unmarshal(out, &param); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"GetInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to decode response from binary: %s", err)
}
if err := e.validateResult(param); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"GetInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to validate result: %s", err)
}
return param, nil
}
// ListInstances will list all instances for a provider.
func (e *external) ListInstances(ctx context.Context, poolID string, _ common.ListInstancesParams) ([]commonParams.ProviderInstance, error) {
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.ListInstancesCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_POOL_ID=%s", poolID),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
}
asEnv = append(asEnv, e.environmentVariables...)
metrics.InstanceOperationCount.WithLabelValues(
"ListInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
out, err := garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"ListInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
return []commonParams.ProviderInstance{}, garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
var param []commonParams.ProviderInstance
if err := json.Unmarshal(out, &param); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"ListInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
return []commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to decode response from binary: %s", err)
}
ret := make([]commonParams.ProviderInstance, len(param))
for idx, inst := range param {
if err := e.validateResult(inst); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"ListInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
return []commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to validate result: %s", err)
}
ret[idx] = inst
}
return ret, nil
}
// RemoveAllInstances will remove all instances created by this provider.
func (e *external) RemoveAllInstances(ctx context.Context, _ common.RemoveAllInstancesParams) error {
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.RemoveAllInstancesCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
}
asEnv = append(asEnv, e.environmentVariables...)
metrics.InstanceOperationCount.WithLabelValues(
"RemoveAllInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
_, err := garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"RemoveAllInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
return garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
return nil
}
// Stop shuts down the instance.
func (e *external) Stop(ctx context.Context, instance string, _ common.StopParams) error {
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.StopInstanceCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_INSTANCE_ID=%s", instance),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
}
asEnv = append(asEnv, e.environmentVariables...)
metrics.InstanceOperationCount.WithLabelValues(
"Stop", // label: operation
e.cfg.Name, // label: provider
).Inc()
_, err := garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"Stop", // label: operation
e.cfg.Name, // label: provider
).Inc()
return garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
return nil
}
// Start boots up an instance.
func (e *external) Start(ctx context.Context, instance string, _ common.StartParams) error {
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.StartInstanceCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_INSTANCE_ID=%s", instance),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
}
asEnv = append(asEnv, e.environmentVariables...)
metrics.InstanceOperationCount.WithLabelValues(
"Start", // label: operation
e.cfg.Name, // label: provider
).Inc()
_, err := garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"Start", // label: operation
e.cfg.Name, // label: provider
).Inc()
return garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
return nil
}
func (e *external) AsParams() params.Provider {
return params.Provider{
Name: e.cfg.Name,
Description: e.cfg.Description,
ProviderType: e.cfg.ProviderType,
}
}
// DisableJITConfig tells us if the provider explicitly disables JIT configuration and
// forces runner registration tokens to be used. This may happen if a provider has not yet
// been updated to support JIT configuration.
func (e *external) DisableJITConfig() bool {
if e.cfg == nil {
return false
}
return e.cfg.DisableJITConfig
}

View file

@ -0,0 +1,394 @@
package v011
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"os/exec"
"github.com/pkg/errors"
garmErrors "github.com/cloudbase/garm-provider-common/errors"
execution "github.com/cloudbase/garm-provider-common/execution/v0.1.1"
commonParams "github.com/cloudbase/garm-provider-common/params"
garmExec "github.com/cloudbase/garm-provider-common/util/exec"
"github.com/cloudbase/garm/config"
"github.com/cloudbase/garm/metrics"
"github.com/cloudbase/garm/params"
"github.com/cloudbase/garm/runner/common"
"github.com/cloudbase/garm/runner/providers/util"
)
var _ common.Provider = (*external)(nil)
func NewProvider(ctx context.Context, cfg *config.Provider, controllerID string) (common.Provider, error) {
if cfg.ProviderType != params.ExternalProvider {
return nil, garmErrors.NewBadRequestError("invalid provider config")
}
execPath, err := cfg.External.ExecutablePath()
if err != nil {
return nil, errors.Wrap(err, "fetching executable path")
}
// Set GARM_INTERFACE_VERSION to the version of the interface that the external
// provider implements. This is used to ensure compatibility between the external
// provider and garm
envVars := cfg.External.GetEnvironmentVariables()
envVars = append(envVars, fmt.Sprintf("GARM_INTERFACE_VERSION=%s", cfg.External.InterfaceVersion))
return &external{
ctx: ctx,
controllerID: controllerID,
cfg: cfg,
execPath: execPath,
environmentVariables: envVars,
}, nil
}
type external struct {
ctx context.Context
cfg *config.Provider
controllerID string
execPath string
environmentVariables []string
}
func (e *external) validateResult(inst commonParams.ProviderInstance) error {
if inst.ProviderID == "" {
return garmErrors.NewProviderError("missing provider ID")
}
if inst.Name == "" {
return garmErrors.NewProviderError("missing instance name")
}
if !util.IsValidProviderStatus(inst.Status) {
return garmErrors.NewProviderError("invalid status returned (%s)", inst.Status)
}
return nil
}
// CreateInstance creates a new compute instance in the provider.
func (e *external) CreateInstance(ctx context.Context, bootstrapParams commonParams.BootstrapInstance, _ common.CreateInstanceParams) (commonParams.ProviderInstance, error) {
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.CreateInstanceCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_POOL_ID=%s", bootstrapParams.PoolID),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
}
asEnv = append(asEnv, e.environmentVariables...)
asJs, err := json.Marshal(bootstrapParams)
if err != nil {
return commonParams.ProviderInstance{}, errors.Wrap(err, "serializing bootstrap params")
}
metrics.InstanceOperationCount.WithLabelValues(
"CreateInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
out, err := garmExec.Exec(ctx, e.execPath, asJs, asEnv)
if err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"CreateInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
var param commonParams.ProviderInstance
if err := json.Unmarshal(out, &param); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"CreateInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to decode response from binary: %s", err)
}
if err := e.validateResult(param); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"CreateInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to validate result: %s", err)
}
retAsJs, _ := json.MarshalIndent(param, "", " ")
slog.DebugContext(
ctx, "provider returned",
"output", string(retAsJs))
return param, nil
}
// Delete instance will delete the instance in a provider.
func (e *external) DeleteInstance(ctx context.Context, instance string, deleteInstanceParams common.DeleteInstanceParams) error {
extraspecs := deleteInstanceParams.DeleteInstanceV011.PoolInfo.ExtraSpecs
extraspecsValue, err := json.Marshal(extraspecs)
if err != nil {
return errors.Wrap(err, "serializing extraspecs")
}
// Encode the extraspecs as base64 to avoid issues with special characters.
base64EncodedExtraSpecs := base64.StdEncoding.EncodeToString(extraspecsValue)
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.DeleteInstanceCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_INSTANCE_ID=%s", instance),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
fmt.Sprintf("GARM_POOL_ID=%s", deleteInstanceParams.DeleteInstanceV011.PoolInfo.ID),
fmt.Sprintf("GARM_POOL_EXTRASPECS=%s", base64EncodedExtraSpecs),
}
asEnv = append(asEnv, e.environmentVariables...)
metrics.InstanceOperationCount.WithLabelValues(
"DeleteInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
_, err = garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err != nil {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != execution.ExitCodeNotFound {
metrics.InstanceOperationFailedCount.WithLabelValues(
"DeleteInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
}
return nil
}
// GetInstance will return details about one instance.
func (e *external) GetInstance(ctx context.Context, instance string, getInstanceParams common.GetInstanceParams) (commonParams.ProviderInstance, error) {
extraspecs := getInstanceParams.GetInstanceV011.PoolInfo.ExtraSpecs
extraspecsValue, err := json.Marshal(extraspecs)
if err != nil {
return commonParams.ProviderInstance{}, errors.Wrap(err, "serializing extraspecs")
}
// Encode the extraspecs as base64 to avoid issues with special characters.
base64EncodedExtraSpecs := base64.StdEncoding.EncodeToString(extraspecsValue)
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.GetInstanceCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_INSTANCE_ID=%s", instance),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
fmt.Sprintf("GARM_POOL_ID=%s", getInstanceParams.GetInstanceV011.PoolInfo.ID),
fmt.Sprintf("GARM_POOL_EXTRASPECS=%s", base64EncodedExtraSpecs),
}
asEnv = append(asEnv, e.environmentVariables...)
// nolint:golangci-lint,godox
// TODO(gabriel-samfira): handle error types. Of particular interest is to
// know when the error is ErrNotFound.
metrics.InstanceOperationCount.WithLabelValues(
"GetInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
out, err := garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"GetInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
var param commonParams.ProviderInstance
if err := json.Unmarshal(out, &param); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"GetInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to decode response from binary: %s", err)
}
if err := e.validateResult(param); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"GetInstance", // label: operation
e.cfg.Name, // label: provider
).Inc()
return commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to validate result: %s", err)
}
return param, nil
}
// ListInstances will list all instances for a provider.
func (e *external) ListInstances(ctx context.Context, poolID string, listInstancesParams common.ListInstancesParams) ([]commonParams.ProviderInstance, error) {
extraspecs := listInstancesParams.ListInstancesV011.PoolInfo.ExtraSpecs
extraspecsValue, err := json.Marshal(extraspecs)
if err != nil {
return []commonParams.ProviderInstance{}, errors.Wrap(err, "serializing extraspecs")
}
// Encode the extraspecs as base64 to avoid issues with special characters.
base64EncodedExtraSpecs := base64.StdEncoding.EncodeToString(extraspecsValue)
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.ListInstancesCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_POOL_ID=%s", poolID),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
fmt.Sprintf("GARM_POOL_EXTRASPECS=%s", base64EncodedExtraSpecs),
}
asEnv = append(asEnv, e.environmentVariables...)
metrics.InstanceOperationCount.WithLabelValues(
"ListInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
out, err := garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err == nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"ListInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
return []commonParams.ProviderInstance{}, garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
var param []commonParams.ProviderInstance
if err := json.Unmarshal(out, &param); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"ListInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
return []commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to decode response from binary: %s", err)
}
ret := make([]commonParams.ProviderInstance, len(param))
for idx, inst := range param {
if err := e.validateResult(inst); err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"ListInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
return []commonParams.ProviderInstance{}, garmErrors.NewProviderError("failed to validate result: %s", err)
}
ret[idx] = inst
}
return ret, nil
}
// RemoveAllInstances will remove all instances created by this provider.
func (e *external) RemoveAllInstances(ctx context.Context, removeAllInstances common.RemoveAllInstancesParams) error {
extraspecs := removeAllInstances.RemoveAllInstancesV011.PoolInfo.ExtraSpecs
extraspecsValue, err := json.Marshal(extraspecs)
if err != nil {
return errors.Wrap(err, "serializing extraspecs")
}
// Encode the extraspecs as base64 to avoid issues with special characters.
base64EncodedExtraSpecs := base64.StdEncoding.EncodeToString(extraspecsValue)
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.RemoveAllInstancesCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
fmt.Sprintf("GARM_POOL_ID=%s", removeAllInstances.RemoveAllInstancesV011.PoolInfo.ID),
fmt.Sprintf("GARM_POOL_EXTRASPECS=%s", base64EncodedExtraSpecs),
}
asEnv = append(asEnv, e.environmentVariables...)
metrics.InstanceOperationCount.WithLabelValues(
"RemoveAllInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
_, err = garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"RemoveAllInstances", // label: operation
e.cfg.Name, // label: provider
).Inc()
return garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
return nil
}
// Stop shuts down the instance.
func (e *external) Stop(ctx context.Context, instance string, stopParams common.StopParams) error {
extraspecs := stopParams.StopV011.PoolInfo.ExtraSpecs
extraspecsValue, err := json.Marshal(extraspecs)
if err != nil {
return errors.Wrap(err, "serializing extraspecs")
}
// Encode the extraspecs as base64 to avoid issues with special characters.
base64EncodedExtraSpecs := base64.StdEncoding.EncodeToString(extraspecsValue)
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.StopInstanceCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_INSTANCE_ID=%s", instance),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
fmt.Sprintf("GARM_POOL_ID=%s", stopParams.StopV011.PoolInfo.ID),
fmt.Sprintf("GARM_POOL_EXTRASPECS=%s", base64EncodedExtraSpecs),
}
asEnv = append(asEnv, e.environmentVariables...)
metrics.InstanceOperationCount.WithLabelValues(
"Stop", // label: operation
e.cfg.Name, // label: provider
).Inc()
_, err = garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"Stop", // label: operation
e.cfg.Name, // label: provider
).Inc()
return garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
return nil
}
// Start boots up an instance.
func (e *external) Start(ctx context.Context, instance string, startParams common.StartParams) error {
extraspecs := startParams.StartV011.PoolInfo.ExtraSpecs
extraspecsValue, err := json.Marshal(extraspecs)
if err != nil {
return errors.Wrap(err, "serializing extraspecs")
}
// Encode the extraspecs as base64 to avoid issues with special characters.
base64EncodedExtraSpecs := base64.StdEncoding.EncodeToString(extraspecsValue)
asEnv := []string{
fmt.Sprintf("GARM_COMMAND=%s", execution.StartInstanceCommand),
fmt.Sprintf("GARM_CONTROLLER_ID=%s", e.controllerID),
fmt.Sprintf("GARM_INSTANCE_ID=%s", instance),
fmt.Sprintf("GARM_PROVIDER_CONFIG_FILE=%s", e.cfg.External.ConfigFile),
fmt.Sprintf("GARM_POOL_ID=%s", startParams.StartV011.PoolInfo.ID),
fmt.Sprintf("GARM_POOL_EXTRASPECS=%s", base64EncodedExtraSpecs),
}
asEnv = append(asEnv, e.environmentVariables...)
metrics.InstanceOperationCount.WithLabelValues(
"Start", // label: operation
e.cfg.Name, // label: provider
).Inc()
_, err = garmExec.Exec(ctx, e.execPath, nil, asEnv)
if err != nil {
metrics.InstanceOperationFailedCount.WithLabelValues(
"Start", // label: operation
e.cfg.Name, // label: provider
).Inc()
return garmErrors.NewProviderError("provider binary %s returned error: %s", e.execPath, err)
}
return nil
}
func (e *external) AsParams() params.Provider {
return params.Provider{
Name: e.cfg.Name,
Description: e.cfg.Description,
ProviderType: e.cfg.ProviderType,
}
}
// DisableJITConfig tells us if the provider explicitly disables JIT configuration and
// forces runner registration tokens to be used. This may happen if a provider has not yet
// been updated to support JIT configuration.
func (e *external) DisableJITConfig() bool {
if e.cfg == nil {
return false
}
return e.cfg.DisableJITConfig
}

View file

@ -0,0 +1,494 @@
// Copyright 2023 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 execution
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"testing"
gErrors "github.com/cloudbase/garm-provider-common/errors"
"github.com/cloudbase/garm-provider-common/params"
"github.com/stretchr/testify/require"
)
type testExternalProvider struct {
mockErr error
mockInstance params.ProviderInstance
}
func (e *testExternalProvider) CreateInstance(ctx context.Context, bootstrapParams params.BootstrapInstance) (params.ProviderInstance, error) {
if e.mockErr != nil {
return params.ProviderInstance{}, e.mockErr
}
return e.mockInstance, nil
}
func (p *testExternalProvider) DeleteInstance(context.Context, string) error {
if p.mockErr != nil {
return p.mockErr
}
return nil
}
func (p *testExternalProvider) GetInstance(context.Context, string) (params.ProviderInstance, error) {
if p.mockErr != nil {
return params.ProviderInstance{}, p.mockErr
}
return p.mockInstance, nil
}
func (p *testExternalProvider) ListInstances(context.Context, string) ([]params.ProviderInstance, error) {
if p.mockErr != nil {
return nil, p.mockErr
}
return []params.ProviderInstance{p.mockInstance}, nil
}
func (p *testExternalProvider) RemoveAllInstances(context.Context) error {
if p.mockErr != nil {
return p.mockErr
}
return nil
}
func (p *testExternalProvider) Stop(context.Context, string, bool) error {
if p.mockErr != nil {
return p.mockErr
}
return nil
}
func (p *testExternalProvider) Start(context.Context, string) error {
if p.mockErr != nil {
return p.mockErr
}
return nil
}
func TestResolveErrorToExitCode(t *testing.T) {
tests := []struct {
name string
err error
code int
}{
{
name: "nil error",
err: nil,
code: 0,
},
{
name: "not found error",
err: gErrors.ErrNotFound,
code: ExitCodeNotFound,
},
{
name: "duplicate entity error",
err: gErrors.ErrDuplicateEntity,
code: ExitCodeDuplicate,
},
{
name: "other error",
err: errors.New("other error"),
code: 1,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
code := ResolveErrorToExitCode(tc.err)
require.Equal(t, tc.code, code)
})
}
}
func TestValidateEnvironment(t *testing.T) {
// Create a temporary file
tmpfile, err := os.CreateTemp("", "provider-config")
if err != nil {
log.Fatal(err)
}
// clean up the temporary file
t.Cleanup(func() { os.RemoveAll(tmpfile.Name()) })
tests := []struct {
name string
env Environment
errString string
}{
{
name: "valid environment",
env: Environment{
Command: CreateInstanceCommand,
ControllerID: "controller-id",
PoolID: "pool-id",
ProviderConfigFile: tmpfile.Name(),
InstanceID: "instance-id",
BootstrapParams: params.BootstrapInstance{
Name: "instance-name",
},
},
errString: "",
},
{
name: "invalid command",
env: Environment{
Command: "",
},
errString: "missing GARM_COMMAND",
},
{
name: "invalid provider config file",
env: Environment{
Command: CreateInstanceCommand,
ProviderConfigFile: "",
},
errString: "missing GARM_PROVIDER_CONFIG_FILE",
},
{
name: "error accessing config file",
env: Environment{
Command: CreateInstanceCommand,
ProviderConfigFile: "invalid-file",
},
errString: "error accessing config file",
},
{
name: "invalid controller ID",
env: Environment{
Command: CreateInstanceCommand,
ProviderConfigFile: tmpfile.Name(),
},
errString: "missing GARM_CONTROLLER_ID",
},
{
name: "invalid instance ID",
env: Environment{
Command: DeleteInstanceCommand,
ProviderConfigFile: tmpfile.Name(),
ControllerID: "controller-id",
InstanceID: "",
},
errString: "missing instance ID",
},
{
name: "invalid pool ID",
env: Environment{
Command: ListInstancesCommand,
ProviderConfigFile: tmpfile.Name(),
ControllerID: "controller-id",
PoolID: "",
},
errString: "missing pool ID",
},
{
name: "invalid bootstrap params",
env: Environment{
Command: CreateInstanceCommand,
ProviderConfigFile: tmpfile.Name(),
ControllerID: "controller-id",
PoolID: "pool-id",
BootstrapParams: params.BootstrapInstance{},
},
errString: "missing bootstrap params",
},
{
name: "missing pool ID",
env: Environment{
Command: CreateInstanceCommand,
ProviderConfigFile: tmpfile.Name(),
ControllerID: "controller-id",
PoolID: "",
BootstrapParams: params.BootstrapInstance{
Name: "instance-name",
},
},
errString: "missing pool ID",
},
{
name: "unknown command",
env: Environment{
Command: "unknown-command",
ProviderConfigFile: tmpfile.Name(),
ControllerID: "controller-id",
PoolID: "pool-id",
BootstrapParams: params.BootstrapInstance{
Name: "instance-name",
},
},
errString: "unknown GARM_COMMAND",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.env.Validate()
if tc.errString == "" {
require.NoError(t, err)
} else {
require.Error(t, err)
require.Regexp(t, tc.errString, err.Error())
}
})
}
}
func TestRun(t *testing.T) {
tests := []struct {
name string
providerEnv Environment
providerInstance params.ProviderInstance
providerErr error
expectedErrMsg string
}{
{
name: "Valid environment",
providerEnv: Environment{
Command: CreateInstanceCommand,
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: nil,
expectedErrMsg: "",
},
{
name: "Failed to create instance",
providerEnv: Environment{
Command: CreateInstanceCommand,
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: fmt.Errorf("error creating test-instance"),
expectedErrMsg: "failed to create instance in provider: error creating test-instance",
},
{
name: "Failed to get instance",
providerEnv: Environment{
Command: GetInstanceCommand,
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: fmt.Errorf("error getting test-instance"),
expectedErrMsg: "failed to get instance from provider: error getting test-instance",
},
{
name: "Failed to list instances",
providerEnv: Environment{
Command: ListInstancesCommand,
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: fmt.Errorf("error listing instances"),
expectedErrMsg: "failed to list instances from provider: error listing instances",
},
{
name: "Failed to delete instance",
providerEnv: Environment{
Command: DeleteInstanceCommand,
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: fmt.Errorf("error deleting test-instance"),
expectedErrMsg: "failed to delete instance from provider: error deleting test-instance",
},
{
name: "Failed to remove all instances",
providerEnv: Environment{
Command: RemoveAllInstancesCommand,
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: fmt.Errorf("error removing all instances"),
expectedErrMsg: "failed to destroy environment: error removing all instances",
},
{
name: "Failed to start instance",
providerEnv: Environment{
Command: StartInstanceCommand,
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: fmt.Errorf("error starting test-instance"),
expectedErrMsg: "failed to start instance: error starting test-instance",
},
{
name: "Failed to stop instance",
providerEnv: Environment{
Command: StopInstanceCommand,
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: fmt.Errorf("error stopping test-instance"),
expectedErrMsg: "failed to stop instance: error stopping test-instance",
},
{
name: "Invalid command",
providerEnv: Environment{
Command: "invalid-command",
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: nil,
expectedErrMsg: "invalid command: invalid-command",
},
}
for _, tc := range tests {
testExternalProvider := testExternalProvider{
mockErr: tc.providerErr,
mockInstance: tc.providerInstance,
}
out, err := Run(context.Background(), &testExternalProvider, tc.providerEnv)
if tc.expectedErrMsg == "" {
require.NoError(t, err)
expectedJs, marshalErr := json.Marshal(tc.providerInstance)
require.NoError(t, marshalErr)
require.Equal(t, string(expectedJs), out)
} else {
require.Equal(t, err.Error(), tc.expectedErrMsg)
require.Equal(t, "", out)
}
}
}
func TestGetEnvironment(t *testing.T) {
tests := []struct {
name string
stdinData string
envData map[string]string
errString string
}{
{
name: "The environment is valid",
stdinData: `{"name": "test"}`,
errString: "",
},
{
name: "Data is missing from stdin",
stdinData: ``,
errString: "CreateInstance requires data passed into stdin",
},
{
name: "Invalid JSON",
stdinData: `bogus`,
errString: "failed to decode instance params: invalid character 'b' looking for beginning of value",
},
}
for _, tc := range tests {
// Create a temporary file
tmpfile, err := os.CreateTemp("", "test-get-env")
if err != nil {
log.Fatal(err)
}
// clean up the temporary file
t.Cleanup(func() { os.RemoveAll(tmpfile.Name()) })
// Write some test data to the temporary file
if _, err := tmpfile.Write([]byte(tc.stdinData)); err != nil {
log.Fatal(err)
}
// Rewind the temporary file to the beginning
if _, err := tmpfile.Seek(0, 0); err != nil {
log.Fatal(err)
}
// Clean up the temporary file
t.Cleanup(func() { os.RemoveAll(tmpfile.Name()) })
oldStdin := os.Stdin
defer func() { os.Stdin = oldStdin }() // Restore original Stdin
os.Stdin = tmpfile // mock os.Stdin
for key, value := range tc.envData {
os.Setenv(key, value)
}
// Define the environment variables
os.Setenv("GARM_COMMAND", "CreateInstance")
os.Setenv("GARM_CONTROLLER_ID", "test-controller-id")
os.Setenv("GARM_POOL_ID", "test-pool-id")
os.Setenv("GARM_PROVIDER_CONFIG_FILE", tmpfile.Name())
// Clean up the environment variables
t.Cleanup(func() {
for key := range tc.envData {
os.Unsetenv(key)
}
})
env, err := GetEnvironment()
if tc.errString == "" {
require.NoError(t, err)
require.Equal(t, CreateInstanceCommand, env.Command)
} else {
require.Equal(t, tc.errString, err.Error())
}
}
}
func TestGetEnvValidateFailed(t *testing.T) {
// Create a temporary file
tmpfile, err := os.CreateTemp("", "test-get-env")
if err != nil {
log.Fatal(err)
}
// clean up the temporary file
t.Cleanup(func() { os.RemoveAll(tmpfile.Name()) })
os.Setenv("GARM_COMMAND", "unknown-command")
os.Setenv("GARM_CONTROLLER_ID", "test-controller-id")
os.Setenv("GARM_POOL_ID", "test-pool-id")
os.Setenv("GARM_PROVIDER_CONFIG_FILE", tmpfile.Name())
// Clean up the environment variables
t.Cleanup(func() {
os.Unsetenv("GARM_COMMAND")
os.Unsetenv("GARM_CONTROLLER_ID")
os.Unsetenv("GARM_POOL_ID")
os.Unsetenv("GARM_PROVIDER_CONFIG_FILE")
})
_, err = GetEnvironment()
require.Error(t, err)
require.Equal(t, "failed to validate execution environment: unknown GARM_COMMAND: unknown-command", err.Error())
}

View file

@ -0,0 +1,28 @@
// Copyright 2023 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 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"
GetVersionInfoCommand ExecutionCommand = "GetVersionInfo"
)

View file

@ -0,0 +1,212 @@
// Copyright 2023 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 execution
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
gErrors "github.com/cloudbase/garm-provider-common/errors"
"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 ResolveErrorToExitCode(err error) int {
if err != nil {
if errors.Is(err, gErrors.ErrNotFound) {
return ExitCodeNotFound
} else if errors.Is(err, gErrors.ErrDuplicateEntity) {
return ExitCodeDuplicate
}
return 1
}
return 0
}
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"),
InterfaceVersion: os.Getenv("GARM_INTERFACE_VERSION"),
ExtraSpecs: os.Getenv("GARM_POOL_EXTRASPECS"),
}
// 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)
}
if bootstrapParams.ExtraSpecs == nil {
// Initialize ExtraSpecs as an empty JSON object
bootstrapParams.ExtraSpecs = json.RawMessage([]byte("{}"))
}
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
InterfaceVersion string
ExtraSpecs 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")
}
if e.PoolID == "" {
return fmt.Errorf("missing pool 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,494 @@
// Copyright 2023 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 execution
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"testing"
gErrors "github.com/cloudbase/garm-provider-common/errors"
"github.com/cloudbase/garm-provider-common/params"
"github.com/stretchr/testify/require"
)
type testExternalProvider struct {
mockErr error
mockInstance params.ProviderInstance
}
func (e *testExternalProvider) CreateInstance(ctx context.Context, bootstrapParams params.BootstrapInstance) (params.ProviderInstance, error) {
if e.mockErr != nil {
return params.ProviderInstance{}, e.mockErr
}
return e.mockInstance, nil
}
func (p *testExternalProvider) DeleteInstance(context.Context, string) error {
if p.mockErr != nil {
return p.mockErr
}
return nil
}
func (p *testExternalProvider) GetInstance(context.Context, string) (params.ProviderInstance, error) {
if p.mockErr != nil {
return params.ProviderInstance{}, p.mockErr
}
return p.mockInstance, nil
}
func (p *testExternalProvider) ListInstances(context.Context, string) ([]params.ProviderInstance, error) {
if p.mockErr != nil {
return nil, p.mockErr
}
return []params.ProviderInstance{p.mockInstance}, nil
}
func (p *testExternalProvider) RemoveAllInstances(context.Context) error {
if p.mockErr != nil {
return p.mockErr
}
return nil
}
func (p *testExternalProvider) Stop(context.Context, string, bool) error {
if p.mockErr != nil {
return p.mockErr
}
return nil
}
func (p *testExternalProvider) Start(context.Context, string) error {
if p.mockErr != nil {
return p.mockErr
}
return nil
}
func TestResolveErrorToExitCode(t *testing.T) {
tests := []struct {
name string
err error
code int
}{
{
name: "nil error",
err: nil,
code: 0,
},
{
name: "not found error",
err: gErrors.ErrNotFound,
code: ExitCodeNotFound,
},
{
name: "duplicate entity error",
err: gErrors.ErrDuplicateEntity,
code: ExitCodeDuplicate,
},
{
name: "other error",
err: errors.New("other error"),
code: 1,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
code := ResolveErrorToExitCode(tc.err)
require.Equal(t, tc.code, code)
})
}
}
func TestValidateEnvironment(t *testing.T) {
// Create a temporary file
tmpfile, err := os.CreateTemp("", "provider-config")
if err != nil {
log.Fatal(err)
}
// clean up the temporary file
t.Cleanup(func() { os.RemoveAll(tmpfile.Name()) })
tests := []struct {
name string
env Environment
errString string
}{
{
name: "valid environment",
env: Environment{
Command: CreateInstanceCommand,
ControllerID: "controller-id",
PoolID: "pool-id",
ProviderConfigFile: tmpfile.Name(),
InstanceID: "instance-id",
BootstrapParams: params.BootstrapInstance{
Name: "instance-name",
},
},
errString: "",
},
{
name: "invalid command",
env: Environment{
Command: "",
},
errString: "missing GARM_COMMAND",
},
{
name: "invalid provider config file",
env: Environment{
Command: CreateInstanceCommand,
ProviderConfigFile: "",
},
errString: "missing GARM_PROVIDER_CONFIG_FILE",
},
{
name: "error accessing config file",
env: Environment{
Command: CreateInstanceCommand,
ProviderConfigFile: "invalid-file",
},
errString: "error accessing config file",
},
{
name: "invalid controller ID",
env: Environment{
Command: CreateInstanceCommand,
ProviderConfigFile: tmpfile.Name(),
},
errString: "missing GARM_CONTROLLER_ID",
},
{
name: "invalid instance ID",
env: Environment{
Command: DeleteInstanceCommand,
ProviderConfigFile: tmpfile.Name(),
ControllerID: "controller-id",
InstanceID: "",
},
errString: "missing instance ID",
},
{
name: "invalid pool ID",
env: Environment{
Command: ListInstancesCommand,
ProviderConfigFile: tmpfile.Name(),
ControllerID: "controller-id",
PoolID: "",
},
errString: "missing pool ID",
},
{
name: "invalid bootstrap params",
env: Environment{
Command: CreateInstanceCommand,
ProviderConfigFile: tmpfile.Name(),
ControllerID: "controller-id",
PoolID: "pool-id",
BootstrapParams: params.BootstrapInstance{},
},
errString: "missing bootstrap params",
},
{
name: "missing pool ID",
env: Environment{
Command: CreateInstanceCommand,
ProviderConfigFile: tmpfile.Name(),
ControllerID: "controller-id",
PoolID: "",
BootstrapParams: params.BootstrapInstance{
Name: "instance-name",
},
},
errString: "missing pool ID",
},
{
name: "unknown command",
env: Environment{
Command: "unknown-command",
ProviderConfigFile: tmpfile.Name(),
ControllerID: "controller-id",
PoolID: "pool-id",
BootstrapParams: params.BootstrapInstance{
Name: "instance-name",
},
},
errString: "unknown GARM_COMMAND",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.env.Validate()
if tc.errString == "" {
require.NoError(t, err)
} else {
require.Error(t, err)
require.Regexp(t, tc.errString, err.Error())
}
})
}
}
func TestRun(t *testing.T) {
tests := []struct {
name string
providerEnv Environment
providerInstance params.ProviderInstance
providerErr error
expectedErrMsg string
}{
{
name: "Valid environment",
providerEnv: Environment{
Command: CreateInstanceCommand,
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: nil,
expectedErrMsg: "",
},
{
name: "Failed to create instance",
providerEnv: Environment{
Command: CreateInstanceCommand,
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: fmt.Errorf("error creating test-instance"),
expectedErrMsg: "failed to create instance in provider: error creating test-instance",
},
{
name: "Failed to get instance",
providerEnv: Environment{
Command: GetInstanceCommand,
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: fmt.Errorf("error getting test-instance"),
expectedErrMsg: "failed to get instance from provider: error getting test-instance",
},
{
name: "Failed to list instances",
providerEnv: Environment{
Command: ListInstancesCommand,
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: fmt.Errorf("error listing instances"),
expectedErrMsg: "failed to list instances from provider: error listing instances",
},
{
name: "Failed to delete instance",
providerEnv: Environment{
Command: DeleteInstanceCommand,
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: fmt.Errorf("error deleting test-instance"),
expectedErrMsg: "failed to delete instance from provider: error deleting test-instance",
},
{
name: "Failed to remove all instances",
providerEnv: Environment{
Command: RemoveAllInstancesCommand,
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: fmt.Errorf("error removing all instances"),
expectedErrMsg: "failed to destroy environment: error removing all instances",
},
{
name: "Failed to start instance",
providerEnv: Environment{
Command: StartInstanceCommand,
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: fmt.Errorf("error starting test-instance"),
expectedErrMsg: "failed to start instance: error starting test-instance",
},
{
name: "Failed to stop instance",
providerEnv: Environment{
Command: StopInstanceCommand,
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: fmt.Errorf("error stopping test-instance"),
expectedErrMsg: "failed to stop instance: error stopping test-instance",
},
{
name: "Invalid command",
providerEnv: Environment{
Command: "invalid-command",
},
providerInstance: params.ProviderInstance{
Name: "test-instance",
OSType: params.Linux,
},
providerErr: nil,
expectedErrMsg: "invalid command: invalid-command",
},
}
for _, tc := range tests {
testExternalProvider := testExternalProvider{
mockErr: tc.providerErr,
mockInstance: tc.providerInstance,
}
out, err := Run(context.Background(), &testExternalProvider, tc.providerEnv)
if tc.expectedErrMsg == "" {
require.NoError(t, err)
expectedJs, marshalErr := json.Marshal(tc.providerInstance)
require.NoError(t, marshalErr)
require.Equal(t, string(expectedJs), out)
} else {
require.Equal(t, err.Error(), tc.expectedErrMsg)
require.Equal(t, "", out)
}
}
}
func TestGetEnvironment(t *testing.T) {
tests := []struct {
name string
stdinData string
envData map[string]string
errString string
}{
{
name: "The environment is valid",
stdinData: `{"name": "test"}`,
errString: "",
},
{
name: "Data is missing from stdin",
stdinData: ``,
errString: "CreateInstance requires data passed into stdin",
},
{
name: "Invalid JSON",
stdinData: `bogus`,
errString: "failed to decode instance params: invalid character 'b' looking for beginning of value",
},
}
for _, tc := range tests {
// Create a temporary file
tmpfile, err := os.CreateTemp("", "test-get-env")
if err != nil {
log.Fatal(err)
}
// clean up the temporary file
t.Cleanup(func() { os.RemoveAll(tmpfile.Name()) })
// Write some test data to the temporary file
if _, err := tmpfile.Write([]byte(tc.stdinData)); err != nil {
log.Fatal(err)
}
// Rewind the temporary file to the beginning
if _, err := tmpfile.Seek(0, 0); err != nil {
log.Fatal(err)
}
// Clean up the temporary file
t.Cleanup(func() { os.RemoveAll(tmpfile.Name()) })
oldStdin := os.Stdin
defer func() { os.Stdin = oldStdin }() // Restore original Stdin
os.Stdin = tmpfile // mock os.Stdin
for key, value := range tc.envData {
os.Setenv(key, value)
}
// Define the environment variables
os.Setenv("GARM_COMMAND", "CreateInstance")
os.Setenv("GARM_CONTROLLER_ID", "test-controller-id")
os.Setenv("GARM_POOL_ID", "test-pool-id")
os.Setenv("GARM_PROVIDER_CONFIG_FILE", tmpfile.Name())
// Clean up the environment variables
t.Cleanup(func() {
for key := range tc.envData {
os.Unsetenv(key)
}
})
env, err := GetEnvironment()
if tc.errString == "" {
require.NoError(t, err)
require.Equal(t, CreateInstanceCommand, env.Command)
} else {
require.Equal(t, tc.errString, err.Error())
}
}
}
func TestGetEnvValidateFailed(t *testing.T) {
// Create a temporary file
tmpfile, err := os.CreateTemp("", "test-get-env")
if err != nil {
log.Fatal(err)
}
// clean up the temporary file
t.Cleanup(func() { os.RemoveAll(tmpfile.Name()) })
os.Setenv("GARM_COMMAND", "unknown-command")
os.Setenv("GARM_CONTROLLER_ID", "test-controller-id")
os.Setenv("GARM_POOL_ID", "test-pool-id")
os.Setenv("GARM_PROVIDER_CONFIG_FILE", tmpfile.Name())
// Clean up the environment variables
t.Cleanup(func() {
os.Unsetenv("GARM_COMMAND")
os.Unsetenv("GARM_CONTROLLER_ID")
os.Unsetenv("GARM_POOL_ID")
os.Unsetenv("GARM_PROVIDER_CONFIG_FILE")
})
_, err = GetEnvironment()
require.Error(t, err)
require.Equal(t, "failed to validate execution environment: unknown GARM_COMMAND: unknown-command", err.Error())
}

View file

@ -0,0 +1,41 @@
// Copyright 2023 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 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
}