diff --git a/RELEASE.md b/RELEASE.md index 93908ea98..d9e515a51 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -13,5 +13,5 @@ ## Footnotes -1. We utilize [semantic versioning](https://semver.org/) and only include relevant/significant changes within the CHANGELOG (be sure to document changes to the app config if `config_version` has changed). +1. We utilize [semantic versioning](https://semver.org/) and only include relevant/significant changes within the CHANGELOG (be sure to document changes to the app config if `config_version` has changed, and if any breaking interface changes are made to the fastly.toml manifest those should be documented on developer.fastly.com). 1. Triggers a [github action](https://github.com/fastly/cli/blob/main/.github/workflows/tag_release.yml) that produces a 'draft' release. diff --git a/pkg/cmd/flags.go b/pkg/cmd/flags.go index e52eacd8d..6f313059a 100644 --- a/pkg/cmd/flags.go +++ b/pkg/cmd/flags.go @@ -235,7 +235,7 @@ func (ac *OptionalAutoClone) Parse(v *fastly.Version, sid string, verbose bool, if verbose { msg := "Service version %d is not editable, so it was automatically cloned because --autoclone is enabled. Now operating on version %d.\n\n" format := fmt.Sprintf(msg, v.Number, version.Number) - text.Output(out, format) + text.Info(out, format) } return version, nil } diff --git a/pkg/commands/compute/compute_mocks_test.go b/pkg/commands/compute/compute_mocks_test.go index ac21c06cc..75f8af521 100644 --- a/pkg/commands/compute/compute_mocks_test.go +++ b/pkg/commands/compute/compute_mocks_test.go @@ -5,8 +5,9 @@ package compute_test // also a mocked HTTP client). import ( - "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v8/fastly" + + "github.com/fastly/cli/pkg/testutil" ) func getServiceOK(i *fastly.GetServiceInput) (*fastly.Service, error) { @@ -38,7 +39,7 @@ func createConfigStoreOK(i *fastly.CreateConfigStoreInput) (*fastly.ConfigStore, }, nil } -func createConfigStoreItemOK(i *fastly.CreateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { +func updateConfigStoreItemOK(i *fastly.UpdateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { return &fastly.ConfigStoreItem{ Key: i.Key, Value: i.Value, @@ -103,6 +104,96 @@ func listDomainsOk(i *fastly.ListDomainsInput) ([]*fastly.Domain, error) { }, nil } +func listKVStoresOk(i *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) { + return &fastly.ListKVStoresResponse{ + Data: []fastly.KVStore{ + { + ID: "123", + Name: "store_one", + }, + { + ID: "456", + Name: "store_two", + }, + }, + }, nil +} + +func listKVStoresEmpty(i *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) { + return &fastly.ListKVStoresResponse{}, nil +} + +func getKVStoreOk(i *fastly.GetKVStoreInput) (*fastly.KVStore, error) { + return &fastly.KVStore{ + ID: "123", + Name: "store_one", + }, nil +} + +func listSecretStoresOk(i *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) { + return &fastly.SecretStores{ + Data: []fastly.SecretStore{ + { + ID: "123", + Name: "store_one", + }, + { + ID: "456", + Name: "store_two", + }, + }, + }, nil +} + +func listSecretStoresEmpty(i *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) { + return &fastly.SecretStores{}, nil +} + +func getSecretStoreOk(i *fastly.GetSecretStoreInput) (*fastly.SecretStore, error) { + return &fastly.SecretStore{ + ID: "123", + Name: "store_one", + }, nil +} + +func createSecretStoreOk(i *fastly.CreateSecretStoreInput) (*fastly.SecretStore, error) { + return &fastly.SecretStore{ + ID: "123", + Name: "store_one", + }, nil +} + +func createSecretOk(i *fastly.CreateSecretInput) (*fastly.Secret, error) { + return &fastly.Secret{ + Digest: []byte("123"), + Name: "foo", + }, nil +} + +func listConfigStoresOk() ([]*fastly.ConfigStore, error) { + return []*fastly.ConfigStore{ + { + ID: "123", + Name: "example", + }, + { + ID: "456", + Name: "example_two", + }, + }, nil +} + +func listConfigStoresEmpty() ([]*fastly.ConfigStore, error) { + return []*fastly.ConfigStore{}, nil +} + +func getConfigStoreOk(i *fastly.GetConfigStoreInput) (*fastly.ConfigStore, error) { + return &fastly.ConfigStore{ + ID: "123", + Name: "example", + }, nil +} + func getServiceDetailsWasm(i *fastly.GetServiceInput) (*fastly.ServiceDetail, error) { return &fastly.ServiceDetail{ Type: "wasm", diff --git a/pkg/commands/compute/deploy.go b/pkg/commands/compute/deploy.go index 354e6caad..e849c4df3 100644 --- a/pkg/commands/compute/deploy.go +++ b/pkg/commands/compute/deploy.go @@ -7,8 +7,10 @@ import ( "io/fs" "net/http" "os" + "os/signal" "path/filepath" "strings" + "syscall" "time" "github.com/fastly/go-fastly/v8/fastly" @@ -175,6 +177,10 @@ func (c *DeployCommand) Exec(in io.Reader, out io.Writer) (err error) { undoStack.RunIfError(out, err) }(c.Globals.ErrLog) + signalCh := make(chan os.Signal, 1) + signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM) + go monitorSignals(signalCh, noExistingService, out, undoStack, spinner) + var serviceVersion *fastly.Version if noExistingService { serviceID, serviceVersion, err = c.NewService(fnActivateTrial, spinner, in, out) @@ -188,7 +194,7 @@ func (c *DeployCommand) Exec(in io.Reader, out io.Writer) (err error) { serviceVersion, err = c.ExistingServiceVersion(serviceID, out) if err != nil { if errors.Is(err, ErrPackageUnchanged) { - text.Info(out, "Skipping package deployment, local and service version are identical. (service %v, version %v) ", serviceID, serviceVersion) + text.Info(out, "Skipping package deployment, local and service version are identical. (service %s, version %d) ", serviceID, serviceVersion.Number) return nil } return err @@ -521,7 +527,7 @@ func (c *DeployCommand) NewService(fnActivateTrial Activator, spinner text.Spinn } text.Break(out) - answer, err := text.AskYesNo(out, text.BoldYellow("Create new service: [y/N] "), in) + answer, err := text.AskYesNo(out, "Create new service: [y/N] ", in) if err != nil { return serviceID, serviceVersion, err } @@ -543,7 +549,7 @@ func (c *DeployCommand) NewService(fnActivateTrial Activator, spinner text.Spinn case c.Globals.Flags.AcceptDefaults || c.Globals.Flags.NonInteractive: serviceName = defaultServiceName default: - serviceName, err = text.Input(out, text.BoldYellow(fmt.Sprintf("Service name: [%s] ", defaultServiceName)), in) + serviceName, err = text.Input(out, text.Prompt(fmt.Sprintf("Service name: [%s] ", defaultServiceName)), in) if err != nil || serviceName == "" { serviceName = defaultServiceName } @@ -726,6 +732,15 @@ func errLogService(l fsterr.LogInterface, err error, sid string, sv int) { // CompareLocalRemotePackage compares the local package files hash against the // existing service package version and exits early with message if identical. +// +// NOTE: We can't avoid the first 'no-changes' upload after the initial deploy. +// This is because the fastly.toml manifest does actual change after first deploy. +// When user first deploys, there is no value for service_id. +// That version of the manifest is inside the package we're checking against. +// So on the second deploy, even if user has made no changes themselves, we will +// still upload that package because technically there was a change made by the +// CLI to add the Service ID. Any subsequent deploys will be aborted because +// there will be no changes made by the CLI nor the user. func (c *DeployCommand) CompareLocalRemotePackage(serviceID string, version int) error { filesHash, err := getFilesHash(c.PackagePath) if err != nil { @@ -1215,3 +1230,15 @@ func (c *DeployCommand) ExistingServiceVersion(serviceID string, out io.Writer) return serviceVersion, nil } + +func monitorSignals(signalCh chan os.Signal, noExistingService bool, out io.Writer, undoStack *undo.Stack, spinner text.Spinner) { + <-signalCh + signal.Stop(signalCh) + spinner.StopFailMessage("Signal received to interrupt/terminate the Fastly CLI process") + spinner.StopFail() + text.Important(out, "\n\nThe Fastly CLI process will be terminated after any clean-up tasks have been processed") + if noExistingService { + undoStack.Unwind(out) + } + os.Exit(1) +} diff --git a/pkg/commands/compute/deploy_test.go b/pkg/commands/compute/deploy_test.go index 8fbb8137c..ad0b6c571 100644 --- a/pkg/commands/compute/deploy_test.go +++ b/pkg/commands/compute/deploy_test.go @@ -198,9 +198,9 @@ func TestDeploy(t *testing.T) { args: args("compute deploy --service-id 123 --token 123 --version 1"), api: mock.API{ CloneVersionFn: testutil.CloneVersionError, + GetPackageFn: getPackageOk, GetServiceDetailsFn: getServiceDetailsWasm, ListVersionsFn: testutil.ListVersions, - GetPackageFn: getPackageOk, }, wantError: fmt.Sprintf("error cloning service version: %s", testutil.Err.Error()), }, @@ -209,11 +209,11 @@ func TestDeploy(t *testing.T) { args: args("compute deploy --service-id 123 --token 123"), api: mock.API{ CloneVersionFn: testutil.CloneVersionResult(4), + GetPackageFn: getPackageOk, GetServiceDetailsFn: getServiceDetailsWasm, GetServiceFn: getServiceOK, ListDomainsFn: listDomainsError, ListVersionsFn: testutil.ListVersions, - GetPackageFn: getPackageOk, }, wantError: fmt.Sprintf("error fetching service domains: %s", testutil.Err.Error()), }, @@ -277,8 +277,8 @@ func TestDeploy(t *testing.T) { args: args("compute deploy --token 123"), api: mock.API{ CreateServiceFn: createServiceErrorNoTrial, - GetCurrentUserFn: getCurrentUserError, DeleteServiceFn: deleteServiceOK, + GetCurrentUserFn: getCurrentUserError, }, stdin: []string{ "Y", // when prompted to create a new service @@ -295,8 +295,8 @@ func TestDeploy(t *testing.T) { args: args("compute deploy --token 123"), api: mock.API{ CreateServiceFn: createServiceErrorNoTrial, - GetCurrentUserFn: getCurrentUser, DeleteServiceFn: deleteServiceOK, + GetCurrentUserFn: getCurrentUser, }, httpClientRes: []*http.Response{ { @@ -324,8 +324,8 @@ func TestDeploy(t *testing.T) { args: args("compute deploy --token 123"), api: mock.API{ CreateServiceFn: createServiceErrorNoTrial, - GetCurrentUserFn: getCurrentUser, DeleteServiceFn: deleteServiceOK, + GetCurrentUserFn: getCurrentUser, }, httpClientRes: []*http.Response{ nil, @@ -430,11 +430,11 @@ func TestDeploy(t *testing.T) { name: "undo stack is executed", args: args("compute deploy --token 123"), api: mock.API{ - CreateServiceFn: createServiceOK, - ListDomainsFn: listDomainsNone, - CreateDomainFn: createDomainOK, CreateBackendFn: createBackendError, + CreateDomainFn: createDomainOK, + CreateServiceFn: createServiceOK, DeleteServiceFn: deleteServiceOK, + ListDomainsFn: listDomainsNone, }, stdin: []string{ "Y", // when prompted to create a new service @@ -455,8 +455,8 @@ func TestDeploy(t *testing.T) { ActivateVersionFn: activateVersionError, CloneVersionFn: testutil.CloneVersionResult(4), GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, @@ -480,8 +480,8 @@ func TestDeploy(t *testing.T) { api: mock.API{ CloneVersionFn: testutil.CloneVersionResult(4), GetPackageFn: getPackageIdentical, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, }, @@ -496,8 +496,8 @@ func TestDeploy(t *testing.T) { ActivateVersionFn: activateVersionOk, CloneVersionFn: testutil.CloneVersionResult(4), GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, @@ -528,8 +528,8 @@ func TestDeploy(t *testing.T) { api: mock.API{ ActivateVersionFn: activateVersionOk, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, @@ -564,8 +564,8 @@ func TestDeploy(t *testing.T) { api: mock.API{ ActivateVersionFn: activateVersionOk, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, @@ -598,8 +598,8 @@ func TestDeploy(t *testing.T) { api: mock.API{ ActivateVersionFn: activateVersionOk, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, @@ -627,8 +627,8 @@ func TestDeploy(t *testing.T) { ActivateVersionFn: activateVersionOk, CloneVersionFn: testutil.CloneVersionResult(4), GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, @@ -656,8 +656,8 @@ func TestDeploy(t *testing.T) { ActivateVersionFn: activateVersionOk, CloneVersionFn: testutil.CloneVersionResult(4), GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, @@ -685,8 +685,8 @@ func TestDeploy(t *testing.T) { ActivateVersionFn: activateVersionOk, CloneVersionFn: testutil.CloneVersionResult(4), GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, @@ -1091,8 +1091,8 @@ func TestDeploy(t *testing.T) { CreateBackendFn: createBackendOK, CreateServiceFn: createServiceOK, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, @@ -1128,8 +1128,8 @@ func TestDeploy(t *testing.T) { CloneVersionFn: testutil.CloneVersionResult(4), CreateBackendFn: createBackendOK, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, @@ -1180,8 +1180,8 @@ func TestDeploy(t *testing.T) { CloneVersionFn: testutil.CloneVersionResult(4), CreateBackendFn: createBackendOK, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, @@ -1231,15 +1231,16 @@ func TestDeploy(t *testing.T) { ActivateVersionFn: activateVersionOk, CreateBackendFn: createBackendOK, CreateConfigStoreFn: createConfigStoreOK, - CreateConfigStoreItemFn: createConfigStoreItemOK, - CreateResourceFn: createResourceOK, CreateDomainFn: createDomainOK, + CreateResourceFn: createResourceOK, CreateServiceFn: createServiceOK, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListConfigStoresFn: listConfigStoresEmpty, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, + UpdateConfigStoreItemFn: updateConfigStoreItemOK, UpdatePackageFn: updatePackageOk, }, httpClientRes: []*http.Response{ @@ -1284,6 +1285,69 @@ func TestDeploy(t *testing.T) { "SUCCESS: Deployed package (service 12345, version 1)", }, }, + { + name: "success with setup.config_stores configuration and no existing service and a conflicting store name", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateConfigStoreFn: createConfigStoreOK, + CreateDomainFn: createDomainOK, + CreateResourceFn: createResourceOK, + CreateServiceFn: createServiceOK, + GetConfigStoreFn: getConfigStoreOk, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListConfigStoresFn: listConfigStoresOk, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdateConfigStoreItemFn: updateConfigStoreItemOK, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + { + Body: io.NopCloser(strings.NewReader("success")), + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + }, + }, + httpClientErr: []error{ + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.config_stores.example] + description = "My first store" + [setup.config_stores.example.items.foo] + value = "my default value for foo" + description = "a good description about foo" + [setup.config_stores.example.items.bar] + value = "my default value for bar" + description = "a good description about bar" + `, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "WARNING: A Config Store called 'example' already exists", + "Retrieving existing Config Store 'example'", + "Configuring config store 'example'", + "My first store", + "Create a config store key called 'foo'", + "my default value for foo", + "Create a config store key called 'bar'", + "my default value for bar", + "Creating config store item 'foo'", + "Creating config store item 'bar'", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + }, { name: "success with setup.config_stores configuration and no existing service and --non-interactive", args: args("compute deploy --non-interactive --token 123"), @@ -1291,15 +1355,16 @@ func TestDeploy(t *testing.T) { ActivateVersionFn: activateVersionOk, CreateBackendFn: createBackendOK, CreateConfigStoreFn: createConfigStoreOK, - CreateConfigStoreItemFn: createConfigStoreItemOK, - CreateResourceFn: createResourceOK, CreateDomainFn: createDomainOK, + CreateResourceFn: createResourceOK, CreateServiceFn: createServiceOK, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListConfigStoresFn: listConfigStoresEmpty, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, + UpdateConfigStoreItemFn: updateConfigStoreItemOK, UpdatePackageFn: updatePackageOk, }, httpClientRes: []*http.Response{ @@ -1345,15 +1410,16 @@ func TestDeploy(t *testing.T) { ActivateVersionFn: activateVersionOk, CreateBackendFn: createBackendOK, CreateConfigStoreFn: createConfigStoreOK, - CreateConfigStoreItemFn: createConfigStoreItemOK, - CreateResourceFn: createResourceOK, CreateDomainFn: createDomainOK, + CreateResourceFn: createResourceOK, CreateServiceFn: createServiceOK, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListConfigStoresFn: listConfigStoresEmpty, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, + UpdateConfigStoreItemFn: updateConfigStoreItemOK, UpdatePackageFn: updatePackageOk, }, httpClientRes: []*http.Response{ @@ -1407,8 +1473,8 @@ func TestDeploy(t *testing.T) { CloneVersionFn: testutil.CloneVersionResult(4), CreateBackendFn: createBackendOK, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, @@ -1455,8 +1521,8 @@ func TestDeploy(t *testing.T) { CreateDomainFn: createDomainOK, CreateServiceFn: createServiceOK, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, @@ -1504,8 +1570,8 @@ func TestDeploy(t *testing.T) { CreateDomainFn: createDomainOK, CreateServiceFn: createServiceOK, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, @@ -1554,8 +1620,8 @@ func TestDeploy(t *testing.T) { CreateDomainFn: createDomainOK, CreateServiceFn: createServiceOK, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, @@ -1601,8 +1667,8 @@ func TestDeploy(t *testing.T) { CloneVersionFn: testutil.CloneVersionResult(4), CreateBackendFn: createBackendOK, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, @@ -1623,7 +1689,7 @@ func TestDeploy(t *testing.T) { language = "rust" [setup.kv_stores.store_one] - description = "My first kv store" + description = "My first KV Store" [setup.kv_stores.store_one.items.foo] value = "my default value for foo" description = "a good description about foo" @@ -1637,29 +1703,30 @@ func TestDeploy(t *testing.T) { "SUCCESS: Deployed package (service 123, version 4)", }, dontWantOutput: []string{ - "Configuring kv store 'store_one'", - "Create a kv store key called 'foo'", - "Create a kv store key called 'bar'", - "Creating kv store 'store_one'", - "Creating kv store key 'foo'", - "Creating kv store key 'bar'", + "Configuring KV Store 'store_one'", + "Create a KV Store key called 'foo'", + "Create a KV Store key called 'bar'", + "Creating KV Store 'store_one'", + "Creating KV Store key 'foo'", + "Creating KV Store key 'bar'", }, }, { - name: "success with setup.kv_stores configuration and no existing service with file", + name: "success with setup.kv_stores configuration and no existing service plus use of file and existing store", args: args("compute deploy --token 123"), api: mock.API{ ActivateVersionFn: activateVersionOk, CreateBackendFn: createBackendOK, - CreateKVStoreFn: createKVStoreOK, - InsertKVStoreKeyFn: createKVStoreItemOK, - CreateResourceFn: createResourceOK, CreateDomainFn: createDomainOK, + CreateResourceFn: createResourceOK, CreateServiceFn: createServiceOK, + GetKVStoreFn: getKVStoreOk, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + InsertKVStoreKeyFn: createKVStoreItemOK, ListDomainsFn: listDomainsOk, + ListKVStoresFn: listKVStoresOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, }, @@ -1679,7 +1746,7 @@ func TestDeploy(t *testing.T) { language = "rust" [setup.kv_stores.store_one] - description = "My first kv store" + description = "My first KV Store" [setup.kv_stores.store_one.items.foo] value = "my default value for foo" description = "a good description about foo" @@ -1694,14 +1761,14 @@ func TestDeploy(t *testing.T) { "Y", // when prompted to create a new service }, wantOutput: []string{ - "Configuring kv store 'store_one'", - "Create a kv store key called 'foo'", - "Create a kv store key called 'bar'", - "Create a kv store key called 'baz'", - "Creating kv store 'store_one'", - "Creating kv store key 'foo'", - "Creating kv store key 'bar'", - "Creating kv store key 'baz'", + "WARNING: A KV Store called 'store_one' already exists", + "Retrieving existing KV Store 'store_one'", + "Create a KV Store key called 'foo'", + "Create a KV Store key called 'bar'", + "Create a KV Store key called 'baz'", + "Creating KV Store key 'foo'", + "Creating KV Store key 'bar'", + "Creating KV Store key 'baz'", "Uploading package", "Activating service", "SUCCESS: Deployed package (service 12345, version 1)", @@ -1712,17 +1779,18 @@ func TestDeploy(t *testing.T) { args: args("compute deploy --token 123"), api: mock.API{ CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, CreateKVStoreFn: createKVStoreOK, - InsertKVStoreKeyFn: createKVStoreItemOK, CreateResourceFn: createResourceOK, - CreateDomainFn: createDomainOK, CreateServiceFn: createServiceOK, + DeleteServiceFn: deleteServiceOK, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + InsertKVStoreKeyFn: createKVStoreItemOK, ListDomainsFn: listDomainsOk, + ListKVStoresFn: listKVStoresEmpty, ListVersionsFn: testutil.ListVersions, - DeleteServiceFn: deleteServiceOK, }, httpClientRes: []*http.Response{ { @@ -1740,7 +1808,7 @@ func TestDeploy(t *testing.T) { language = "rust" [setup.kv_stores.store_one] - description = "My first kv store" + description = "My first KV Store" [setup.kv_stores.store_one.items.baz] value = "some_value" file = "./kv_store_one_baz.txt" @@ -1750,7 +1818,7 @@ func TestDeploy(t *testing.T) { "Y", // when prompted to create a new service }, wantOutput: []string{ - "Configuring kv store 'store_one'", + "Configuring KV Store 'store_one'", }, wantError: "invalid config: both 'value' and 'file' were set", }, @@ -1760,15 +1828,16 @@ func TestDeploy(t *testing.T) { api: mock.API{ ActivateVersionFn: activateVersionOk, CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, CreateKVStoreFn: createKVStoreOK, - InsertKVStoreKeyFn: createKVStoreItemOK, CreateResourceFn: createResourceOK, - CreateDomainFn: createDomainOK, CreateServiceFn: createServiceOK, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + InsertKVStoreKeyFn: createKVStoreItemOK, ListDomainsFn: listDomainsOk, + ListKVStoresFn: listKVStoresEmpty, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, }, @@ -1788,7 +1857,7 @@ func TestDeploy(t *testing.T) { language = "rust" [setup.kv_stores.store_one] - description = "My first kv store" + description = "My first KV Store" [setup.kv_stores.store_one.items.foo] value = "my default value for foo" description = "a good description about foo" @@ -1800,9 +1869,9 @@ func TestDeploy(t *testing.T) { "Y", // when prompted to create a new service }, wantOutput: []string{ - "Creating kv store 'store_one'", - "Creating kv store key 'foo'", - "Creating kv store key 'bar'", + "Creating KV Store 'store_one'", + "Creating KV Store key 'foo'", + "Creating KV Store key 'bar'", "Uploading package", "Activating service", "SUCCESS: Deployed package (service 12345, version 1)", @@ -1814,15 +1883,16 @@ func TestDeploy(t *testing.T) { api: mock.API{ ActivateVersionFn: activateVersionOk, CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, CreateKVStoreFn: createKVStoreOK, - InsertKVStoreKeyFn: createKVStoreItemOK, CreateResourceFn: createResourceOK, - CreateDomainFn: createDomainOK, CreateServiceFn: createServiceOK, GetPackageFn: getPackageOk, - GetServiceFn: getServiceOK, GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + InsertKVStoreKeyFn: createKVStoreItemOK, ListDomainsFn: listDomainsOk, + ListKVStoresFn: listKVStoresEmpty, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, }, @@ -1849,12 +1919,195 @@ func TestDeploy(t *testing.T) { "Y", // when prompted to create a new service }, wantOutput: []string{ - "Configuring kv store 'store_one'", - "Create a kv store key called 'foo'", - "Create a kv store key called 'bar'", - "Creating kv store 'store_one'", - "Creating kv store key 'foo'", - "Creating kv store key 'bar'", + "Configuring KV Store 'store_one'", + "Create a KV Store key called 'foo'", + "Create a KV Store key called 'bar'", + "Creating KV Store 'store_one'", + "Creating KV Store key 'foo'", + "Creating KV Store key 'bar'", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + // The following are predefined values for the `description` and `value` + // fields from the prior setup.dictionaries tests that we expect to not + // be present in the stdout/stderr as the [setup/dictionaries] + // configuration does not define them. + dontWantOutput: []string{ + "My first KV Store", + "my default value for foo", + "my default value for bar", + }, + }, + // NOTE: The following test validates [setup] only works for a new service. + { + name: "success with setup.secret_stores configuration and existing service", + args: args("compute deploy --service-id 123 --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBackendFn: createBackendOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + { + Body: io.NopCloser(strings.NewReader("success")), + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + }, + }, + httpClientErr: []error{ + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.secret_stores.store_one] + description = "My first Secret Store" + [setup.secret_stores.store_one.entries.foo] + description = "a good description about foo" + [setup.secret_stores.store_one.entries.bar] + description = "a good description about bar" + `, + wantOutput: []string{ + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 123, version 4)", + }, + dontWantOutput: []string{ + "Configuring Secret Store 'store_one'", + "Create a Secret Store entry called 'foo'", + "Create a Secret Store entry called 'bar'", + "Creating Secret Store 'store_one'", + "Creating Secret Store entry 'foo'", + "Creating Secret Store entry 'bar'", + }, + }, + { + name: "success with setup.secret_stores configuration and no existing service but an existing store", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateResourceFn: createResourceOK, + CreateSecretFn: createSecretOk, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + GetSecretStoreFn: getSecretStoreOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListSecretStoresFn: listSecretStoresOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + { + Body: io.NopCloser(strings.NewReader("success")), + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + }, + }, + httpClientErr: []error{ + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.secret_stores.store_one] + description = "My first Secret Store" + [setup.secret_stores.store_one.entries.foo] + description = "a good description about foo" + [setup.secret_stores.store_one.entries.bar] + description = "a good description about bar" + [setup.secret_stores.store_one.entries.baz] + description = "a file containing the data for this entry" + `, + stdin: []string{ + "Y", // when prompted to create a new service + "", // leave blank for service name prompt + "", // leave blank for backend prompt + "", // leave blank for using existing store prompt + "my_secret", // when prompted to add a secret for foo (this can't be empty) + "my_secret", // when prompted to add a secret for bar (this can't be empty) + "my_secret", // when prompted to add a secret for baz (this can't be empty) + }, + wantOutput: []string{ + "WARNING: A Secret Store called 'store_one' already exists", + "Retrieving existing Secret Store 'store_one'", + "Create a Secret Store entry called 'foo'", + "Create a Secret Store entry called 'bar'", + "Create a Secret Store entry called 'baz'", + "Creating Secret Store entry 'foo'", + "Creating Secret Store entry 'bar'", + "Creating Secret Store entry 'baz'", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + }, + { + name: "success with setup.secret_stores configuration and no existing service and no predefined values", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateResourceFn: createResourceOK, + CreateSecretFn: createSecretOk, + CreateSecretStoreFn: createSecretStoreOk, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListSecretStoresFn: listSecretStoresEmpty, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + { + Body: io.NopCloser(strings.NewReader("success")), + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + }, + }, + httpClientErr: []error{ + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.secret_stores.store_one] + [setup.secret_stores.store_one.entries.foo] + [setup.secret_stores.store_one.entries.bar] + `, + stdin: []string{ + "Y", // when prompted to create a new service + "", // leave blank for service name prompt + "", // leave blank for backend prompt + "my_secret", // when prompted to add a secret for foo (this can't be empty) + "my_secret", // when prompted to add a secret for bar (this can't be empty) + }, + wantOutput: []string{ + "Configuring Secret Store 'store_one'", + "Create a Secret Store entry called 'foo'", + "Create a Secret Store entry called 'bar'", + "Creating Secret Store 'store_one'", + "Creating Secret Store entry 'foo'", + "Creating Secret Store entry 'bar'", "Uploading package", "Activating service", "SUCCESS: Deployed package (service 12345, version 1)", @@ -1864,7 +2117,7 @@ func TestDeploy(t *testing.T) { // be present in the stdout/stderr as the [setup/dictionaries] // configuration does not define them. dontWantOutput: []string{ - "My first kv store", + "My first Secret Store", "my default value for foo", "my default value for bar", }, @@ -1954,6 +2207,7 @@ func TestDeploy(t *testing.T) { case <-done: // Wait for app.Run() to finish case <-time.After(10 * time.Second): + t.Log(stdout.String()) t.Fatalf("unexpected timeout waiting for mocked prompt inputs to be processed") } } else { diff --git a/pkg/commands/compute/init.go b/pkg/commands/compute/init.go index 805fd19e6..b3001149d 100644 --- a/pkg/commands/compute/init.go +++ b/pkg/commands/compute/init.go @@ -73,8 +73,7 @@ func (c *InitCommand) Exec(in io.Reader, out io.Writer) (err error) { introContext = " (using --from to locate package template)" } - text.Output(out, "Creating a new Compute project%s.", introContext) - text.Break(out) + text.Output(out, "Creating a new Compute project%s.\n\n", introContext) text.Output(out, "Press ^C at any time to quit.") if c.cloneFrom != "" && c.language == "" { diff --git a/pkg/commands/compute/serve.go b/pkg/commands/compute/serve.go index 2d3245b6a..0838e1387 100644 --- a/pkg/commands/compute/serve.go +++ b/pkg/commands/compute/serve.go @@ -805,8 +805,7 @@ func watchFiles(root string, gi *ignore.GitIgnore, verbose bool, s *fstexec.Stre } if verbose { - text.Output(out, "%s", text.BoldYellow("Watching...")) - text.Break(out) + text.Output(out, "%s\n\n", text.BoldYellow("Watching...")) text.Output(out, buf.String()) text.Break(out) } diff --git a/pkg/commands/compute/setup/backend.go b/pkg/commands/compute/setup/backend.go index 9fe644f74..fa37c38eb 100644 --- a/pkg/commands/compute/setup/backend.go +++ b/pkg/commands/compute/setup/backend.go @@ -158,7 +158,7 @@ func (b *Backends) checkPredefined() error { defaultAddress = settings.Address } - prompt := text.BoldYellow(fmt.Sprintf("Hostname or IP address: [%s] ", defaultAddress)) + prompt := text.Prompt(fmt.Sprintf("Hostname or IP address: [%s] ", defaultAddress)) if !b.AcceptDefaults && !b.NonInteractive { addr, err = text.Input(b.Stdout, prompt, b.Stdin, b.validateAddress) @@ -175,7 +175,7 @@ func (b *Backends) checkPredefined() error { port = settings.Port } if !b.AcceptDefaults && !b.NonInteractive { - input, err := text.Input(b.Stdout, text.BoldYellow(fmt.Sprintf("Port: [%d] ", port)), b.Stdin) + input, err := text.Input(b.Stdout, text.Prompt(fmt.Sprintf("Port: [%d] ", port)), b.Stdin) if err != nil { return fmt.Errorf("error reading prompt input: %w", err) } @@ -217,7 +217,7 @@ func (b *Backends) promptForBackend() error { } i++ - addr, err := text.Input(b.Stdout, text.BoldYellow("Backend (hostname or IP address, or leave blank to stop adding backends): "), b.Stdin, b.validateAddress) + addr, err := text.Input(b.Stdout, text.Prompt("Backend (hostname or IP address, or leave blank to stop adding backends): "), b.Stdin, b.validateAddress) if err != nil { return fmt.Errorf("error reading prompt input %w", err) } @@ -231,7 +231,7 @@ func (b *Backends) promptForBackend() error { } port := int(443) - input, err := text.Input(b.Stdout, text.BoldYellow(fmt.Sprintf("Backend port number: [%d] ", port)), b.Stdin) + input, err := text.Input(b.Stdout, text.Prompt(fmt.Sprintf("Backend port number: [%d] ", port)), b.Stdin) if err != nil { return fmt.Errorf("error reading prompt input: %w", err) } @@ -244,7 +244,7 @@ func (b *Backends) promptForBackend() error { } defaultName := fmt.Sprintf("backend_%d", i) - name, err := text.Input(b.Stdout, text.BoldYellow(fmt.Sprintf("Backend name: [%s] ", defaultName)), b.Stdin) + name, err := text.Input(b.Stdout, text.Prompt(fmt.Sprintf("Backend name: [%s] ", defaultName)), b.Stdin) if err != nil { return fmt.Errorf("error reading prompt input %w", err) } diff --git a/pkg/commands/compute/setup/config_store.go b/pkg/commands/compute/setup/config_store.go index 4e08e6d7d..efcb6e6a3 100644 --- a/pkg/commands/compute/setup/config_store.go +++ b/pkg/commands/compute/setup/config_store.go @@ -35,8 +35,10 @@ type ConfigStores struct { // ConfigStore represents the configuration parameters for creating a config // store via the API client. type ConfigStore struct { - Name string - Items []ConfigStoreItem + Name string + Items []ConfigStoreItem + LinkExistingStore bool + ExistingStoreID string } // ConfigStoreItem represents the configuration parameters for creating config @@ -48,10 +50,41 @@ type ConfigStoreItem struct { // Configure prompts the user for specific values related to the service resource. func (o *ConfigStores) Configure() error { + existingStores, err := o.APIClient.ListConfigStores() + if err != nil { + return err + } + for name, settings := range o.Setup { + var ( + existingStoreID string + linkExistingStore bool + ) + + for _, store := range existingStores { + if store.Name == name { + if o.AcceptDefaults || o.NonInteractive { + linkExistingStore = true + existingStoreID = store.ID + } else { + text.Warning(o.Stdout, "\nA Config Store called '%s' already exists. If you use this store, then this implies that any keys defined in your setup configuration will either be newly created or will update an existing one. To avoid updating an existing key, then stop the command now and edit the setup configuration before re-running the deployment process\n\n", name) + prompt := text.Prompt("Use a different store name (or leave empty to use the existing store): ") + value, err := text.Input(o.Stdout, prompt, o.Stdin) + if err != nil { + return fmt.Errorf("error reading prompt input: %w", err) + } + if value == "" { + linkExistingStore = true + existingStoreID = store.ID + } else { + name = value + } + } + } + } + if !o.AcceptDefaults && !o.NonInteractive { - text.Break(o.Stdout) - text.Output(o.Stdout, "Configuring config store '%s'", name) + text.Output(o.Stdout, "\nConfiguring config store '%s'", name) if settings.Description != "" { text.Output(o.Stdout, settings.Description) } @@ -64,7 +97,7 @@ func (o *ConfigStores) Configure() error { if item.Value != "" { dv = item.Value } - prompt := text.BoldYellow(fmt.Sprintf("Value: [%s] ", dv)) + prompt := text.Prompt(fmt.Sprintf("Value: [%s] ", dv)) var ( value string @@ -72,8 +105,7 @@ func (o *ConfigStores) Configure() error { ) if !o.AcceptDefaults && !o.NonInteractive { - text.Break(o.Stdout) - text.Output(o.Stdout, "Create a config store key called '%s'", key) + text.Output(o.Stdout, "\nCreate a config store key called '%s'", key) if item.Description != "" { text.Output(o.Stdout, item.Description) } @@ -96,8 +128,10 @@ func (o *ConfigStores) Configure() error { } o.required = append(o.required, ConfigStore{ - Name: name, - Items: items, + Name: name, + Items: items, + LinkExistingStore: linkExistingStore, + ExistingStoreID: existingStoreID, }) } @@ -113,91 +147,73 @@ func (o *ConfigStores) Create() error { } } - for _, store := range o.required { - err := o.Spinner.Start() - if err != nil { - return err - } - msg := fmt.Sprintf("Creating config store '%s'", store.Name) - o.Spinner.Message(msg + "...") + for _, configStore := range o.required { + var ( + err error + cs *fastly.ConfigStore + ) - cs, err := o.APIClient.CreateConfigStore(&fastly.CreateConfigStoreInput{ - Name: store.Name, - }) - if err != nil { - err = fmt.Errorf("error creating config store: %w", err) - o.Spinner.StopFailMessage(msg) - spinErr := o.Spinner.StopFail() - if spinErr != nil { - return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) - } - return err - } - - o.Spinner.StopMessage(msg) - err = o.Spinner.Stop() - if err != nil { - return err - } - - if len(store.Items) > 0 { - for _, item := range store.Items { - err := o.Spinner.Start() + if configStore.LinkExistingStore { + err = o.Spinner.Process(fmt.Sprintf("Retrieving existing Config Store '%s'", configStore.Name), func(_ *text.SpinnerWrapper) error { + cs, err = o.APIClient.GetConfigStore(&fastly.GetConfigStoreInput{ + ID: configStore.ExistingStoreID, + }) if err != nil { - return err + return fmt.Errorf("failed to get existing store '%s': %w", configStore.Name, err) } - msg := fmt.Sprintf("Creating config store item '%s'", item.Key) - o.Spinner.Message(msg + "...") - - _, err = o.APIClient.CreateConfigStoreItem(&fastly.CreateConfigStoreItemInput{ - StoreID: cs.ID, - Key: item.Key, - Value: item.Value, + return nil + }) + if err != nil { + return err + } + } else { + err = o.Spinner.Process(fmt.Sprintf("Creating config store '%s'", configStore.Name), func(_ *text.SpinnerWrapper) error { + cs, err = o.APIClient.CreateConfigStore(&fastly.CreateConfigStoreInput{ + Name: configStore.Name, }) if err != nil { - err = fmt.Errorf("error creating config store item: %w", err) - o.Spinner.StopFailMessage(msg) - spinErr := o.Spinner.StopFail() - if spinErr != nil { - return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) - } - return err + return fmt.Errorf("error creating config store: %w", err) } + return nil + }) + if err != nil { + return err + } + } - o.Spinner.StopMessage(msg) - err = o.Spinner.Stop() + if len(configStore.Items) > 0 { + for _, item := range configStore.Items { + err = o.Spinner.Process(fmt.Sprintf("Creating config store item '%s'", item.Key), func(_ *text.SpinnerWrapper) error { + _, err = o.APIClient.UpdateConfigStoreItem(&fastly.UpdateConfigStoreItemInput{ + Upsert: true, // Use upsert to avoid conflicts when reusing a starter kit. + StoreID: cs.ID, + Key: item.Key, + Value: item.Value, + }) + if err != nil { + return fmt.Errorf("error creating config store item: %w", err) + } + return nil + }) if err != nil { return err } } } - err = o.Spinner.Start() - if err != nil { - return err - } - msg = fmt.Sprintf("Creating resource link between service and config store '%s'...", cs.Name) - o.Spinner.Message(msg) - // IMPORTANT: We need to link the config store to the Compute Service. - _, err = o.APIClient.CreateResource(&fastly.CreateResourceInput{ - ServiceID: o.ServiceID, - ServiceVersion: o.ServiceVersion, - Name: fastly.String(cs.Name), - ResourceID: fastly.String(cs.ID), - }) - if err != nil { - err = fmt.Errorf("error creating resource link between the service '%s' and the config store '%s': %w", o.ServiceID, store.Name, err) - o.Spinner.StopFailMessage(msg) - spinErr := o.Spinner.StopFail() - if spinErr != nil { - return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + err = o.Spinner.Process(fmt.Sprintf("Creating resource link between service and config store '%s'...", cs.Name), func(_ *text.SpinnerWrapper) error { + _, err = o.APIClient.CreateResource(&fastly.CreateResourceInput{ + ServiceID: o.ServiceID, + ServiceVersion: o.ServiceVersion, + Name: fastly.String(cs.Name), + ResourceID: fastly.String(cs.ID), + }) + if err != nil { + return fmt.Errorf("error creating resource link between the service '%s' and the config store '%s': %w", o.ServiceID, configStore.Name, err) } - return err - } - - o.Spinner.StopMessage(msg) - err = o.Spinner.Stop() + return nil + }) if err != nil { return err } diff --git a/pkg/commands/compute/setup/domain.go b/pkg/commands/compute/setup/domain.go index ccb82398a..120222c51 100644 --- a/pkg/commands/compute/setup/domain.go +++ b/pkg/commands/compute/setup/domain.go @@ -68,7 +68,7 @@ func (d *Domains) Configure() error { ) if !d.AcceptDefaults && !d.NonInteractive { text.Break(d.Stdout) - domain, err = text.Input(d.Stdout, text.BoldYellow(fmt.Sprintf("Domain: [%s] ", defaultDomain)), d.Stdin, d.validateDomain) + domain, err = text.Input(d.Stdout, text.Prompt(fmt.Sprintf("Domain: [%s] ", defaultDomain)), d.Stdin, d.validateDomain) if err != nil { return fmt.Errorf("error reading input %w", err) } @@ -191,7 +191,7 @@ func (d *Domains) createDomain(name string, attempt int) error { defaultDomain := generateDomainName() if !d.AcceptDefaults && !d.NonInteractive { text.Break(d.Stdout) - domain, err = text.Input(d.Stdout, text.BoldYellow(fmt.Sprintf("Domain already taken, please choose another (attempt %d of %d): [%s] ", attempt, d.RetryLimit, defaultDomain)), d.Stdin, d.validateDomain) + domain, err = text.Input(d.Stdout, text.Prompt(fmt.Sprintf("Domain already taken, please choose another (attempt %d of %d): [%s] ", attempt, d.RetryLimit, defaultDomain)), d.Stdin, d.validateDomain) if err != nil { return fmt.Errorf("error reading input %w", err) } diff --git a/pkg/commands/compute/setup/kv_store.go b/pkg/commands/compute/setup/kv_store.go index 414f0434c..c82782fa0 100644 --- a/pkg/commands/compute/setup/kv_store.go +++ b/pkg/commands/compute/setup/kv_store.go @@ -14,7 +14,7 @@ import ( "github.com/fastly/cli/pkg/text" ) -// KVStores represents the service state related to kv stores defined +// KVStores represents the service state related to KV Stores defined // within the fastly.toml [setup] configuration. // // NOTE: It implements the setup.Interface interface. @@ -34,15 +34,17 @@ type KVStores struct { required []KVStore } -// KVStore represents the configuration parameters for creating an -// kv store via the API client. +// KVStore represents the configuration parameters for creating a KV Store via +// the API client. type KVStore struct { - Name string - Items []KVStoreItem + Name string + Items []KVStoreItem + LinkExistingStore bool + ExistingStoreID string } -// KVStoreItem represents the configuration parameters for creating -// kv store items via the API client. +// KVStoreItem represents the configuration parameters for creating KV Store +// items via the API client. type KVStoreItem struct { Key string Value string @@ -51,10 +53,63 @@ type KVStoreItem struct { // Configure prompts the user for specific values related to the service resource. func (o *KVStores) Configure() error { + var ( + cursor string + existingStores []fastly.KVStore + ) + + for { + kvs, err := o.APIClient.ListKVStores(&fastly.ListKVStoresInput{ + Cursor: cursor, + }) + if err != nil { + return err + } + + if kvs != nil { + for _, store := range kvs.Data { + // Avoid gosec loop aliasing check + store := store + existingStores = append(existingStores, store) + } + if cur, ok := kvs.Meta["next_cursor"]; ok && cur != "" && cur != cursor { + cursor = cur + continue + } + break + } + } + for name, settings := range o.Setup { + var ( + existingStoreID string + linkExistingStore bool + ) + + for _, store := range existingStores { + if store.Name == name { + if o.AcceptDefaults || o.NonInteractive { + linkExistingStore = true + existingStoreID = store.ID + } else { + text.Warning(o.Stdout, "\nA KV Store called '%s' already exists\n\n", name) + prompt := text.Prompt("Use a different store name (or leave empty to use the existing store): ") + value, err := text.Input(o.Stdout, prompt, o.Stdin) + if err != nil { + return fmt.Errorf("error reading prompt input: %w", err) + } + if value == "" { + linkExistingStore = true + existingStoreID = store.ID + } else { + name = value + } + } + } + } + if !o.AcceptDefaults && !o.NonInteractive { - text.Break(o.Stdout) - text.Output(o.Stdout, "Configuring kv store '%s'", name) + text.Output(o.Stdout, "\nConfiguring KV Store '%s'", name) if settings.Description != "" { text.Output(o.Stdout, settings.Description) } @@ -78,7 +133,7 @@ func (o *KVStores) Configure() error { promptMessage = "File" dv = item.File } - prompt := text.BoldYellow(fmt.Sprintf("%s: [%s] ", promptMessage, dv)) + prompt := text.Prompt(fmt.Sprintf("%s: [%s] ", promptMessage, dv)) var ( value string @@ -86,8 +141,7 @@ func (o *KVStores) Configure() error { ) if !o.AcceptDefaults && !o.NonInteractive { - text.Break(o.Stdout) - text.Output(o.Stdout, "Create a kv store key called '%s'", key) + text.Output(o.Stdout, "\nCreate a KV Store key called '%s'", key) if item.Description != "" { text.Output(o.Stdout, item.Description) } @@ -97,7 +151,6 @@ func (o *KVStores) Configure() error { if err != nil { return fmt.Errorf("error reading prompt input: %w", err) } - text.Break(o.Stdout) } if value == "" { value = dv @@ -134,8 +187,10 @@ func (o *KVStores) Configure() error { } o.required = append(o.required, KVStore{ - Name: name, - Items: items, + Name: name, + Items: items, + LinkExistingStore: linkExistingStore, + ExistingStoreID: existingStoreID, }) } @@ -152,95 +207,76 @@ func (o *KVStores) Create() error { } for _, kvStore := range o.required { - err := o.Spinner.Start() - if err != nil { - return err - } - msg := fmt.Sprintf("Creating kv store '%s'", kvStore.Name) - o.Spinner.Message(msg + "...") + var ( + err error + store *fastly.KVStore + ) - store, err := o.APIClient.CreateKVStore(&fastly.CreateKVStoreInput{ - Name: kvStore.Name, - }) - if err != nil { - err = fmt.Errorf("error creating kv store: %w", err) - o.Spinner.StopFailMessage(msg) - spinErr := o.Spinner.StopFail() - if spinErr != nil { - return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + if kvStore.LinkExistingStore { + err = o.Spinner.Process(fmt.Sprintf("Retrieving existing KV Store '%s'", kvStore.Name), func(_ *text.SpinnerWrapper) error { + store, err = o.APIClient.GetKVStore(&fastly.GetKVStoreInput{ + ID: kvStore.ExistingStoreID, + }) + if err != nil { + return fmt.Errorf("failed to get existing store '%s': %w", kvStore.Name, err) + } + return nil + }) + if err != nil { + return err + } + } else { + err = o.Spinner.Process(fmt.Sprintf("Creating KV Store '%s'", kvStore.Name), func(_ *text.SpinnerWrapper) error { + store, err = o.APIClient.CreateKVStore(&fastly.CreateKVStoreInput{ + Name: kvStore.Name, + }) + if err != nil { + return fmt.Errorf("error creating KV Store: %w", err) + } + return nil + }) + if err != nil { + return err } - return err - } - - o.Spinner.StopMessage(msg) - err = o.Spinner.Stop() - if err != nil { - return err } if len(kvStore.Items) > 0 { for _, item := range kvStore.Items { - err := o.Spinner.Start() - if err != nil { - return err - } - msg := fmt.Sprintf("Creating kv store key '%s'...", item.Key) - o.Spinner.Message(msg) - - input := &fastly.InsertKVStoreKeyInput{ - ID: store.ID, - Key: item.Key, - } - if item.Body != nil { - input.Body = item.Body - } else { - input.Value = item.Value - } - err = o.APIClient.InsertKVStoreKey(input) - if err != nil { - err = fmt.Errorf("error creating kv store key: %w", err) - o.Spinner.StopFailMessage(msg) - spinErr := o.Spinner.StopFail() - if spinErr != nil { - return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + err = o.Spinner.Process(fmt.Sprintf("Creating KV Store key '%s'...", item.Key), func(_ *text.SpinnerWrapper) error { + input := &fastly.InsertKVStoreKeyInput{ + ID: store.ID, + Key: item.Key, } - return err - } - - o.Spinner.StopMessage(msg) - err = o.Spinner.Stop() + if item.Body != nil { + input.Body = item.Body + } else { + input.Value = item.Value + } + err = o.APIClient.InsertKVStoreKey(input) + if err != nil { + return fmt.Errorf("error creating KV Store key: %w", err) + } + return nil + }) if err != nil { return err } } } - err = o.Spinner.Start() - if err != nil { - return err - } - msg = fmt.Sprintf("Creating resource link between service and kv store '%s'...", kvStore.Name) - o.Spinner.Message(msg) - - // IMPORTANT: We need to link the kv store to the Compute Service. - _, err = o.APIClient.CreateResource(&fastly.CreateResourceInput{ - ServiceID: o.ServiceID, - ServiceVersion: o.ServiceVersion, - Name: fastly.String(store.Name), - ResourceID: fastly.String(store.ID), - }) - if err != nil { - err = fmt.Errorf("error creating resource link between the service '%s' and the kv store '%s': %w", o.ServiceID, store.Name, err) - o.Spinner.StopFailMessage(msg) - spinErr := o.Spinner.StopFail() - if spinErr != nil { - return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + // IMPORTANT: We need to link the KV Store to the Compute Service. + err = o.Spinner.Process(fmt.Sprintf("Creating resource link between service and KV Store '%s'...", kvStore.Name), func(_ *text.SpinnerWrapper) error { + _, err = o.APIClient.CreateResource(&fastly.CreateResourceInput{ + ServiceID: o.ServiceID, + ServiceVersion: o.ServiceVersion, + Name: fastly.String(store.Name), + ResourceID: fastly.String(store.ID), + }) + if err != nil { + return fmt.Errorf("error creating resource link between the service '%s' and the KV Store '%s': %w", o.ServiceID, store.Name, err) } - return err - } - - o.Spinner.StopMessage(msg) - err = o.Spinner.Stop() + return nil + }) if err != nil { return err } diff --git a/pkg/commands/compute/setup/secret_store.go b/pkg/commands/compute/setup/secret_store.go index 806ada3d3..1015d6f7c 100644 --- a/pkg/commands/compute/setup/secret_store.go +++ b/pkg/commands/compute/setup/secret_store.go @@ -36,8 +36,10 @@ type SecretStores struct { // SecretStore represents the configuration parameters for creating a // secret store via the API client. type SecretStore struct { - Name string - Entries []SecretStoreEntry + Name string + Entries []SecretStoreEntry + LinkExistingStore bool + ExistingStoreID string } // SecretStoreEntry represents the configuration parameters for creating @@ -55,18 +57,68 @@ func (s *SecretStores) Predefined() bool { // Configure prompts the user for specific values related to the service resource. func (s *SecretStores) Configure() error { + var ( + cursor string + existingStores []fastly.SecretStore + ) + + for { + o, err := s.APIClient.ListSecretStores(&fastly.ListSecretStoresInput{ + Cursor: cursor, + }) + if err != nil { + return err + } + if o != nil { + existingStores = append(existingStores, o.Data...) + if o.Meta.NextCursor != "" { + cursor = o.Meta.NextCursor + continue + } + break + } + } + for name, settings := range s.Setup { + var ( + existingStoreID string + linkExistingStore bool + ) + + for _, store := range existingStores { + if store.Name == name { + if s.AcceptDefaults || s.NonInteractive { + linkExistingStore = true + existingStoreID = store.ID + } else { + text.Warning(s.Stdout, "\nA Secret Store called '%s' already exists\n\n", name) + prompt := text.Prompt("Use a different store name (or leave empty to use the existing store): ") + value, err := text.Input(s.Stdout, prompt, s.Stdin) + if err != nil { + return fmt.Errorf("error reading prompt input: %w", err) + } + if value == "" { + linkExistingStore = true + existingStoreID = store.ID + } else { + name = value + } + } + } + } + if !s.AcceptDefaults && !s.NonInteractive { - text.Break(s.Stdout) - text.Output(s.Stdout, "Configuring secret store '%s'", name) + text.Output(s.Stdout, "\nConfiguring Secret Store '%s'", name) if settings.Description != "" { text.Output(s.Stdout, settings.Description) } } store := SecretStore{ - Name: name, - Entries: make([]SecretStoreEntry, 0, len(settings.Entries)), + Name: name, + Entries: make([]SecretStoreEntry, 0, len(settings.Entries)), + LinkExistingStore: linkExistingStore, + ExistingStoreID: existingStoreID, } for key, entry := range settings.Entries { @@ -76,14 +128,13 @@ func (s *SecretStores) Configure() error { ) if !s.AcceptDefaults && !s.NonInteractive { - text.Break(s.Stdout) - text.Output(s.Stdout, "Create a secret store entry called '%s'", key) + text.Output(s.Stdout, "\nCreate a Secret Store entry called '%s'", key) if entry.Description != "" { text.Output(s.Stdout, entry.Description) } text.Break(s.Stdout) - prompt := text.BoldYellow("Value: ") + prompt := text.Prompt("Value: ") value, err = text.InputSecure(s.Stdout, prompt, s.Stdin) if err != nil { return fmt.Errorf("error reading prompt input: %w", err) @@ -116,80 +167,71 @@ func (s *SecretStores) Create() error { } for _, secretStore := range s.required { - if err := s.Spinner.Start(); err != nil { - return err - } - msg := fmt.Sprintf("Creating secret store '%s'", secretStore.Name) - s.Spinner.Message(msg + "...") - - store, err := s.APIClient.CreateSecretStore(&fastly.CreateSecretStoreInput{ - Name: secretStore.Name, - }) - if err != nil { - err = fmt.Errorf("error creating secret store %q: %w", secretStore.Name, err) - s.Spinner.StopFailMessage(msg) - if spinErr := s.Spinner.StopFail(); spinErr != nil { - return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) - } - return err - } - s.Spinner.StopMessage(msg) - if err = s.Spinner.Stop(); err != nil { - return err - } - - for _, entry := range secretStore.Entries { - if err = s.Spinner.Start(); err != nil { + var ( + err error + store *fastly.SecretStore + ) + + if secretStore.LinkExistingStore { + err = s.Spinner.Process(fmt.Sprintf("Retrieving existing Secret Store '%s'", secretStore.Name), func(_ *text.SpinnerWrapper) error { + store, err = s.APIClient.GetSecretStore(&fastly.GetSecretStoreInput{ + ID: secretStore.ExistingStoreID, + }) + if err != nil { + return fmt.Errorf("failed to get existing store '%s': %w", secretStore.Name, err) + } + return nil + }) + if err != nil { return err } - msg = fmt.Sprintf("Creating secret store entry '%s'...", entry.Name) - s.Spinner.Message(msg) - - _, err = s.APIClient.CreateSecret(&fastly.CreateSecretInput{ - ID: store.ID, - Name: entry.Name, - Secret: []byte(entry.Secret), + } else { + err = s.Spinner.Process(fmt.Sprintf("Creating Secret Store '%s'", secretStore.Name), func(_ *text.SpinnerWrapper) error { + store, err = s.APIClient.CreateSecretStore(&fastly.CreateSecretStoreInput{ + Name: secretStore.Name, + }) + if err != nil { + return fmt.Errorf("error creating Secret Store %q: %w", secretStore.Name, err) + } + return nil }) if err != nil { - err = fmt.Errorf("error creating secret store entry %q: %w", entry.Name, err) - s.Spinner.StopFailMessage(msg) - if spinErr := s.Spinner.StopFail(); spinErr != nil { - return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) - } return err } + } - s.Spinner.StopMessage(msg) - if err = s.Spinner.Stop(); err != nil { + for _, entry := range secretStore.Entries { + err = s.Spinner.Process(fmt.Sprintf("Creating Secret Store entry '%s'...", entry.Name), func(_ *text.SpinnerWrapper) error { + _, err = s.APIClient.CreateSecret(&fastly.CreateSecretInput{ + ID: store.ID, + Name: entry.Name, + Secret: []byte(entry.Secret), + }) + if err != nil { + return fmt.Errorf("error creating Secret Store entry %q: %w", entry.Name, err) + } + return nil + }) + if err != nil { return err } } - if err = s.Spinner.Start(); err != nil { - return err - } - msg = fmt.Sprintf("Creating resource link between service and secret store '%s'...", store.Name) - s.Spinner.Message(msg) - - // We need to link the secret store to the Compute Service, otherwise the service - // will not have access to the store. - _, err = s.APIClient.CreateResource(&fastly.CreateResourceInput{ - ServiceID: s.ServiceID, - ServiceVersion: s.ServiceVersion, - Name: fastly.String(store.Name), - ResourceID: fastly.String(store.ID), + err = s.Spinner.Process(fmt.Sprintf("Creating resource link between service and Secret Store '%s'...", store.Name), func(_ *text.SpinnerWrapper) error { + // We need to link the secret store to the C@E Service, otherwise the service + // will not have access to the store. + _, err = s.APIClient.CreateResource(&fastly.CreateResourceInput{ + ServiceID: s.ServiceID, + ServiceVersion: s.ServiceVersion, + Name: fastly.String(store.Name), + ResourceID: fastly.String(store.ID), + }) + if err != nil { + return fmt.Errorf("error creating resource link between the service %q and the Secret Store %q: %w", s.ServiceID, store.Name, err) + } + return nil }) if err != nil { - err = fmt.Errorf("error creating resource link between the service %q and the secret store %q: %w", s.ServiceID, store.Name, err) - s.Spinner.StopFailMessage(msg) - if spinErr := s.Spinner.StopFail(); spinErr != nil { - return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) - } - return err - } - - s.Spinner.StopMessage(msg) - if err = s.Spinner.Stop(); err != nil { return err } } diff --git a/pkg/commands/dictionary/dictionary_test.go b/pkg/commands/dictionary/dictionary_test.go index 411b88944..12555dff0 100644 --- a/pkg/commands/dictionary/dictionary_test.go +++ b/pkg/commands/dictionary/dictionary_test.go @@ -470,7 +470,7 @@ var updateDictionaryOutputVerbose = strings.Join( "", "Service ID (via --service-id): 123", "", - "Service version 1 is not editable, so it was automatically cloned because --autoclone is enabled. Now operating on", + "INFO: Service version 1 is not editable, so it was automatically cloned because --autoclone is enabled. Now operating on", "version 4.", "", strings.TrimSpace(updateDictionaryNameOutput), diff --git a/pkg/commands/profile/create.go b/pkg/commands/profile/create.go index be19ea303..b052c6a24 100644 --- a/pkg/commands/profile/create.go +++ b/pkg/commands/profile/create.go @@ -101,7 +101,7 @@ func (c *CreateCommand) tokenFlow(def bool, in io.Reader, out io.Writer) error { func promptForToken(in io.Reader, out io.Writer, errLog fsterr.LogInterface) (string, error) { text.Output(out, "\nAn API token is used to authenticate requests to the Fastly API. To create a token, visit https://manage.fastly.com/account/personal/tokens\n\n") - token, err := text.InputSecure(out, text.BoldYellow("Fastly API token: "), in, validateTokenNotEmpty) + token, err := text.InputSecure(out, text.Prompt("Fastly API token: "), in, validateTokenNotEmpty) if err != nil { errLog.Add(err) return "", err @@ -234,7 +234,7 @@ func displayCfgPath(path string, out io.Writer) { } func (c *CreateCommand) promptForDefault(in io.Reader, out io.Writer) (bool, error) { - cont, err := text.AskYesNo(out, text.BoldYellow("Set this profile to be your default? [y/N] "), in) + cont, err := text.AskYesNo(out, "Set this profile to be your default? [y/N] ", in) if err != nil { c.Globals.ErrLog.Add(err) return false, err diff --git a/pkg/commands/profile/update.go b/pkg/commands/profile/update.go index 525cd83ef..58b31b310 100644 --- a/pkg/commands/profile/update.go +++ b/pkg/commands/profile/update.go @@ -71,7 +71,7 @@ func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { opts := []profile.EditOption{} - token, err := text.InputSecure(out, text.BoldYellow("Profile token: (leave blank to skip): "), in) + token, err := text.InputSecure(out, text.Prompt("Profile token: (leave blank to skip): "), in) if err != nil { c.Globals.ErrLog.Add(err) return err @@ -85,7 +85,7 @@ func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Break(out) text.Break(out) - makeDefault, err := text.AskYesNo(out, text.BoldYellow("Make profile the default? [y/N] "), in) + makeDefault, err := text.AskYesNo(out, "Make profile the default? [y/N] ", in) if err != nil { return err } diff --git a/pkg/text/color.go b/pkg/text/color.go index 9c1670160..482e9c73d 100644 --- a/pkg/text/color.go +++ b/pkg/text/color.go @@ -5,6 +5,9 @@ import "github.com/fatih/color" // Bold is a Sprint-class function that makes the arguments bold. var Bold = color.New(color.Bold).SprintFunc() +// BoldCyan is a Sprint-class function that makes the arguments bold and cyan. +var BoldCyan = color.New(color.Bold, color.FgCyan).SprintFunc() + // BoldRed is a Sprint-class function that makes the arguments bold and red. var BoldRed = color.New(color.Bold, color.FgRed).SprintFunc() @@ -17,5 +20,8 @@ var BoldGreen = color.New(color.Bold, color.FgGreen).SprintFunc() // Reset is a Sprint-class function that resets the color for the arguments. var Reset = color.New(color.Reset).SprintFunc() +// Prompt is a Sprint-class function that makes the arguments bold and grey. +var Prompt = color.New(color.Bold, color.FgHiBlack).SprintFunc() + // ColorFn is a function returned from a color.SprintFunc() call. type ColorFn func(a ...any) string diff --git a/pkg/text/text.go b/pkg/text/text.go index 7ccc5ada0..52ed2807c 100644 --- a/pkg/text/text.go +++ b/pkg/text/text.go @@ -176,7 +176,7 @@ outer: // one of true (yes and its variants) or false (no, its variants and // anything else) on success. func AskYesNo(w io.Writer, prompt string, r io.Reader) (bool, error) { - answer, err := Input(w, prompt, r) + answer, err := Input(w, Prompt(prompt), r) if err != nil { return false, fmt.Errorf("error reading input %w", err) } @@ -222,13 +222,22 @@ func Error(w io.Writer, format string, args ...any) { fmt.Fprintf(w, WrapString(BoldRed, "ERROR", txt, prefix, suffix), args...) } +// Important is a wrapper for fmt.Fprintf with a bold yellow "IMPORTANT: " prefix. +func Important(w io.Writer, format string, args ...any) { + prefix, suffix, txt := ParseBreaks(format) + if suffix == 0 { + suffix++ + } + fmt.Fprintf(w, WrapString(BoldYellow, "IMPORTANT", txt, prefix, suffix), args...) +} + // Info is a wrapper for fmt.Fprintf with a bold "INFO: " prefix. func Info(w io.Writer, format string, args ...any) { prefix, suffix, txt := ParseBreaks(format) if suffix == 0 { suffix++ } - fmt.Fprintf(w, WrapString(Bold, "INFO", txt, prefix, suffix), args...) + fmt.Fprintf(w, WrapString(BoldCyan, "INFO", txt, prefix, suffix), args...) } // Success is a wrapper for fmt.Fprintf with a bold green "SUCCESS: " prefix. diff --git a/pkg/undo/undo.go b/pkg/undo/undo.go index 3a021a027..9ceb1b083 100644 --- a/pkg/undo/undo.go +++ b/pkg/undo/undo.go @@ -3,6 +3,8 @@ package undo import ( "fmt" "io" + + "github.com/fastly/cli/pkg/text" ) // Fn is a function with no arguments which returns an error or nil. @@ -71,3 +73,13 @@ func (s *Stack) RunIfError(w io.Writer, err error) { } } } + +// Unwind unwinds the stack by serially calling each Fn function state in FIFO +// order. If any Fn returns an error, it gets logged to the provided writer. +func (s *Stack) Unwind(w io.Writer) { + for i := len(s.states) - 1; i >= 0; i-- { + if err := s.states[i](); err != nil { + text.Error(w, "failed to execute clean-up task: %s", err.Error()) + } + } +}