diff --git a/config/external.go b/config/external.go index 9f592fb3..ca98bdfb 100644 --- a/config/external.go +++ b/config/external.go @@ -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 diff --git a/runner/common/mocks/Provider.go b/runner/common/mocks/Provider.go index 1390d972..92dece39 100644 --- a/runner/common/mocks/Provider.go +++ b/runner/common/mocks/Provider.go @@ -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 { diff --git a/runner/common/params.go b/runner/common/params.go new file mode 100644 index 00000000..fdf73dbc --- /dev/null +++ b/runner/common/params.go @@ -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 +} diff --git a/runner/common/provider.go b/runner/common/provider.go index 8ed98e0c..7454540f 100644 --- a/runner/common/provider.go +++ b/runner/common/provider.go @@ -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. diff --git a/runner/pool/pool.go b/runner/pool/pool.go index 09383e34..384bd517 100644 --- a/runner/pool/pool.go +++ b/runner/pool/pool.go @@ -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") } diff --git a/runner/providers/external/external.go b/runner/providers/external/external.go index 446f69a5..23b9b894 100644 --- a/runner/providers/external/external.go +++ b/runner/providers/external/external.go @@ -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, ¶m); 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, ¶m); 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, ¶m); 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 -} diff --git a/runner/providers/external/util.go b/runner/providers/util/util.go similarity index 97% rename from runner/providers/external/util.go rename to runner/providers/util/util.go index 460714e9..2948730b 100644 --- a/runner/providers/external/util.go +++ b/runner/providers/util/util.go @@ -1,4 +1,4 @@ -package external +package util import ( commonParams "github.com/cloudbase/garm-provider-common/params" diff --git a/runner/providers/v0.1.0/external.go b/runner/providers/v0.1.0/external.go new file mode 100644 index 00000000..5f47002e --- /dev/null +++ b/runner/providers/v0.1.0/external.go @@ -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, ¶m); 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, ¶m); 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, ¶m); 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 +} diff --git a/runner/providers/v0.1.1/external.go b/runner/providers/v0.1.1/external.go new file mode 100644 index 00000000..babc0a65 --- /dev/null +++ b/runner/providers/v0.1.1/external.go @@ -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, ¶m); 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, ¶m); 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, ¶m); 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 +} diff --git a/vendor/github.com/cloudbase/garm-provider-common/execution/commands.go b/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.0/commands.go similarity index 100% rename from vendor/github.com/cloudbase/garm-provider-common/execution/commands.go rename to vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.0/commands.go diff --git a/vendor/github.com/cloudbase/garm-provider-common/execution/execution.go b/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.0/execution.go similarity index 100% rename from vendor/github.com/cloudbase/garm-provider-common/execution/execution.go rename to vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.0/execution.go diff --git a/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.0/execution_test.go b/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.0/execution_test.go new file mode 100644 index 00000000..459f9d8b --- /dev/null +++ b/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.0/execution_test.go @@ -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()) +} diff --git a/vendor/github.com/cloudbase/garm-provider-common/execution/interface.go b/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.0/interface.go similarity index 100% rename from vendor/github.com/cloudbase/garm-provider-common/execution/interface.go rename to vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.0/interface.go diff --git a/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.1/commands.go b/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.1/commands.go new file mode 100644 index 00000000..2b42efc8 --- /dev/null +++ b/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.1/commands.go @@ -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" +) diff --git a/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.1/execution.go b/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.1/execution.go new file mode 100644 index 00000000..42947575 --- /dev/null +++ b/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.1/execution.go @@ -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 +} diff --git a/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.1/execution_test.go b/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.1/execution_test.go new file mode 100644 index 00000000..459f9d8b --- /dev/null +++ b/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.1/execution_test.go @@ -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()) +} diff --git a/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.1/interface.go b/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.1/interface.go new file mode 100644 index 00000000..24e39e09 --- /dev/null +++ b/vendor/github.com/cloudbase/garm-provider-common/execution/v0.1.1/interface.go @@ -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 +}