diff --git a/pkg/commands/compute/deploy.go b/pkg/commands/compute/deploy.go index 0030a6a91..f68295e52 100644 --- a/pkg/commands/compute/deploy.go +++ b/pkg/commands/compute/deploy.go @@ -126,16 +126,15 @@ func (c *DeployCommand) Exec(in io.Reader, out io.Writer) (err error) { return nil } - domains, backends, dictionaries, loggers, kvStores, err := constructSetupKVs( + so, err := constructSetupObjects( newService, serviceID, serviceVersion.Number, c, in, out, ) if err != nil { return err } - if err := processSetupConfig( - newService, domains, backends, dictionaries, loggers, kvStores, - serviceID, serviceVersion.Number, c, + if err = processSetupConfig( + newService, so, serviceID, serviceVersion.Number, c, ); err != nil { return err } @@ -147,9 +146,8 @@ func (c *DeployCommand) Exec(in io.Reader, out io.Writer) (err error) { undoStack.RunIfError(out, err) }(c.Globals.ErrLog) - if err := processSetupCreation( - newService, domains, backends, dictionaries, kvStores, spinner, c, - serviceID, serviceVersion.Number, + if err = processSetupCreation( + newService, so, spinner, c, serviceID, serviceVersion.Number, ); err != nil { return err } @@ -164,7 +162,7 @@ func (c *DeployCommand) Exec(in io.Reader, out io.Writer) (err error) { return nil } - if err := processService(c, serviceID, serviceVersion.Number, spinner); err != nil { + if err = processService(c, serviceID, serviceVersion.Number, spinner); err != nil { return err } @@ -839,29 +837,35 @@ func pkgUpload(spinner text.Spinner, client api.Interface, serviceID string, ver return spinner.Stop() } -func constructSetupKVs( +// setupObjects is a collection of backend objects created during setup. +// Objects may be nil. +type setupObjects struct { + domains *setup.Domains + backends *setup.Backends + dictionaries *setup.Dictionaries + loggers *setup.Loggers + kvStores *setup.KVStores + secretStores *setup.SecretStores +} + +func constructSetupObjects( newService bool, serviceID string, serviceVersion int, c *DeployCommand, in io.Reader, out io.Writer, -) ( - *setup.Domains, - *setup.Backends, - *setup.Dictionaries, - *setup.Loggers, - *setup.KVStores, - error, -) { - var err error +) (setupObjects, error) { + var ( + so setupObjects + err error + ) // We only check the Service ID is valid when handling an existing service. if !newService { - err = checkServiceID(serviceID, c.Globals.APIClient) - if err != nil { + if err = checkServiceID(serviceID, c.Globals.APIClient); err != nil { errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) - return nil, nil, nil, nil, nil, err + return setupObjects{}, err } } @@ -869,7 +873,7 @@ func constructSetupKVs( // e.g. it could be missing required resources such as a domain or backend. // We check and allow the user to configure these settings before continuing. - domains := &setup.Domains{ + so.domains = &setup.Domains{ APIClient: c.Globals.APIClient, AcceptDefaults: c.Globals.Flags.AcceptDefaults, NonInteractive: c.Globals.Flags.NonInteractive, @@ -882,21 +886,13 @@ func constructSetupKVs( Verbose: c.Globals.Verbose(), } - err = domains.Validate() - if err != nil { + if err = so.domains.Validate(); err != nil { errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) - return nil, nil, nil, nil, nil, fmt.Errorf("error configuring service domains: %w", err) + return setupObjects{}, fmt.Errorf("error configuring service domains: %w", err) } - var ( - backends *setup.Backends - dictionaries *setup.Dictionaries - loggers *setup.Loggers - kvStores *setup.KVStores - ) - if newService { - backends = &setup.Backends{ + so.backends = &setup.Backends{ APIClient: c.Globals.APIClient, AcceptDefaults: c.Globals.Flags.AcceptDefaults, NonInteractive: c.Globals.Flags.NonInteractive, @@ -907,7 +903,7 @@ func constructSetupKVs( Stdout: out, } - dictionaries = &setup.Dictionaries{ + so.dictionaries = &setup.Dictionaries{ APIClient: c.Globals.APIClient, AcceptDefaults: c.Globals.Flags.AcceptDefaults, NonInteractive: c.Globals.Flags.NonInteractive, @@ -918,12 +914,12 @@ func constructSetupKVs( Stdout: out, } - loggers = &setup.Loggers{ + so.loggers = &setup.Loggers{ Setup: c.Manifest.File.Setup.Loggers, Stdout: out, } - kvStores = &setup.KVStores{ + so.kvStores = &setup.KVStores{ APIClient: c.Globals.APIClient, AcceptDefaults: c.Globals.Flags.AcceptDefaults, NonInteractive: c.Globals.Flags.NonInteractive, @@ -933,25 +929,31 @@ func constructSetupKVs( Stdin: in, Stdout: out, } + + so.secretStores = &setup.SecretStores{ + APIClient: c.Globals.APIClient, + AcceptDefaults: c.Globals.Flags.AcceptDefaults, + NonInteractive: c.Globals.Flags.NonInteractive, + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Setup: c.Manifest.File.Setup.SecretStores, + Stdin: in, + Stdout: out, + } } - return domains, backends, dictionaries, loggers, kvStores, nil + return so, nil } func processSetupConfig( newService bool, - domains *setup.Domains, - backends *setup.Backends, - dictionaries *setup.Dictionaries, - loggers *setup.Loggers, - kvStores *setup.KVStores, + so setupObjects, serviceID string, serviceVersion int, c *DeployCommand, -) (err error) { - if domains.Missing() { - err = domains.Configure() - if err != nil { +) error { + if so.domains.Missing() { + if err := so.domains.Configure(); err != nil { errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) return fmt.Errorf("error configuring service domains: %w", err) } @@ -965,37 +967,41 @@ func processSetupConfig( // the .Predefined() method, as the call to .Configure() will ensure the // user is prompted regardless of whether there is a [setup.backends] // defined in the fastly.toml configuration. - err = backends.Configure() - if err != nil { + if err := so.backends.Configure(); err != nil { errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) return fmt.Errorf("error configuring service backends: %w", err) } - if dictionaries.Predefined() { - err = dictionaries.Configure() - if err != nil { + if so.dictionaries.Predefined() { + if err := so.dictionaries.Configure(); err != nil { errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) return fmt.Errorf("error configuring service dictionaries: %w", err) } } - if loggers.Predefined() { + if so.loggers.Predefined() { // NOTE: We don't handle errors from the Configure() method because we // don't actually do anything other than display a message to the user // informing them that they need to create a log endpoint and which // provider type they should be. The reason we don't implement logic for // creating logging objects is because the API input fields vary // significantly between providers. - _ = loggers.Configure() + _ = so.loggers.Configure() } - if kvStores.Predefined() { - err = kvStores.Configure() - if err != nil { + if so.kvStores.Predefined() { + if err := so.kvStores.Configure(); err != nil { errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) return fmt.Errorf("error configuring service kv stores: %w", err) } } + + if so.secretStores.Predefined() { + if err := so.secretStores.Configure(); err != nil { + errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) + return fmt.Errorf("error configuring service secret stores: %w", err) + } + } } return nil @@ -1003,19 +1009,16 @@ func processSetupConfig( func processSetupCreation( newService bool, - domains *setup.Domains, - backends *setup.Backends, - dictionaries *setup.Dictionaries, - kvStores *setup.KVStores, + so setupObjects, spinner text.Spinner, c *DeployCommand, serviceID string, serviceVersion int, ) error { - if domains.Missing() { - domains.Spinner = spinner + if so.domains.Missing() { + so.domains.Spinner = spinner - if err := domains.Create(); err != nil { + if err := so.domains.Create(); err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Accept defaults": c.Globals.Flags.AcceptDefaults, "Auto-yes": c.Globals.Flags.AutoYes, @@ -1030,11 +1033,23 @@ func processSetupCreation( // IMPORTANT: The pointer refs in this block are not checked for nil. // We presume if we're dealing with newService they have been set. if newService { - backends.Spinner = spinner - dictionaries.Spinner = spinner - kvStores.Spinner = spinner + so.backends.Spinner = spinner + so.dictionaries.Spinner = spinner + so.kvStores.Spinner = spinner + so.secretStores.Spinner = spinner + + if err := so.backends.Create(); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Accept defaults": c.Globals.Flags.AcceptDefaults, + "Auto-yes": c.Globals.Flags.AutoYes, + "Non-interactive": c.Globals.Flags.NonInteractive, + "Service ID": serviceID, + "Service Version": serviceVersion, + }) + return err + } - if err := backends.Create(); err != nil { + if err := so.dictionaries.Create(); err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Accept defaults": c.Globals.Flags.AcceptDefaults, "Auto-yes": c.Globals.Flags.AutoYes, @@ -1045,7 +1060,7 @@ func processSetupCreation( return err } - if err := dictionaries.Create(); err != nil { + if err := so.kvStores.Create(); err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Accept defaults": c.Globals.Flags.AcceptDefaults, "Auto-yes": c.Globals.Flags.AutoYes, @@ -1056,7 +1071,7 @@ func processSetupCreation( return err } - if err := kvStores.Create(); err != nil { + if err := so.secretStores.Create(); err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Accept defaults": c.Globals.Flags.AcceptDefaults, "Auto-yes": c.Globals.Flags.AutoYes, diff --git a/pkg/commands/compute/setup/secret_store.go b/pkg/commands/compute/setup/secret_store.go new file mode 100644 index 000000000..97c8b07cd --- /dev/null +++ b/pkg/commands/compute/setup/secret_store.go @@ -0,0 +1,194 @@ +package setup + +import ( + "errors" + "fmt" + "io" + + "github.com/fastly/cli/pkg/api" + fsterrors "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v8/fastly" +) + +// SecretStores represents the service state related to secret stores defined +// within the fastly.toml [setup] configuration. +// +// NOTE: It implements the setup.Interface interface. +type SecretStores struct { + // Public + APIClient api.Interface + AcceptDefaults bool + NonInteractive bool + Spinner text.Spinner + ServiceID string + ServiceVersion int + Setup map[string]*manifest.SetupSecretStore + Stdin io.Reader + Stdout io.Writer + + // Private + required []SecretStore +} + +// SecretStore represents the configuration parameters for creating a +// secret store via the API client. +type SecretStore struct { + Name string + Entries []SecretStoreEntry +} + +// SecretStoreEntry represents the configuration parameters for creating +// secret store items via the API client. +type SecretStoreEntry struct { + Name string + Secret string +} + +// Predefined indicates if the service resource has been specified within the +// fastly.toml file using a [setup] configuration block. +func (s *SecretStores) Predefined() bool { + return len(s.Setup) > 0 +} + +// Configure prompts the user for specific values related to the service resource. +func (s *SecretStores) Configure() error { + for name, settings := range s.Setup { + if !s.AcceptDefaults && !s.NonInteractive { + text.Break(s.Stdout) + text.Output(s.Stdout, "Configuring secret store '%s'", name) + if settings.Description != "" { + text.Output(s.Stdout, settings.Description) + } + } + + store := SecretStore{ + Name: name, + Entries: make([]SecretStoreEntry, 0, len(settings.Entries)), + } + + for key, entry := range settings.Entries { + var ( + value string + err error + ) + + if !s.AcceptDefaults && !s.NonInteractive { + text.Break(s.Stdout) + text.Output(s.Stdout, "Create a secret store entry called '%s'", key) + if entry.Description != "" { + text.Output(s.Stdout, entry.Description) + } + text.Break(s.Stdout) + + prompt := text.BoldYellow("Value: ") + value, err = text.InputSecure(s.Stdout, prompt, s.Stdin) + if err != nil { + return fmt.Errorf("error reading prompt input: %w", err) + } + } + + if value == "" { + return errors.New("value cannot be blank") + } + + store.Entries = append(store.Entries, SecretStoreEntry{ + Name: key, + Secret: value, + }) + } + + s.required = append(s.required, store) + } + + return nil +} + +// Create calls the relevant API to create the service resource(s). +func (s *SecretStores) Create() error { + if s.Spinner == nil { + return fsterrors.RemediationError{ + Inner: fmt.Errorf("internal logic error: no spinner configured for setup.SecretStores"), + Remediation: fsterrors.BugRemediation, + } + } + + 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 { + s.Spinner.StopFailMessage(msg) + if serr := s.Spinner.StopFail(); serr != nil { + return serr + } + return fmt.Errorf("error creating secret store %q: %w", secretStore.Name, 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 { + 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), + }) + if err != nil { + s.Spinner.StopFailMessage(msg) + if serr := s.Spinner.StopFail(); serr != nil { + return serr + } + return fmt.Errorf("error creating secret store entry %q: %w", entry.Name, err) + } + + s.Spinner.StopMessage(msg) + if err = s.Spinner.Stop(); 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 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 { + s.Spinner.StopFailMessage(msg) + if serr := s.Spinner.StopFail(); serr != nil { + return serr + } + return fmt.Errorf("error creating resource link between the service %q and the secret store %q: %w", s.ServiceID, store.Name, err) + } + + s.Spinner.StopMessage(msg) + if err = s.Spinner.Stop(); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go index badcc6547..b2cc7ab5a 100644 --- a/pkg/manifest/manifest.go +++ b/pkg/manifest/manifest.go @@ -218,10 +218,11 @@ type Scripts struct { // Setup represents a set of service configuration that works with the code in // the package. See https://developer.fastly.com/reference/fastly-toml/. type Setup struct { - Backends map[string]*SetupBackend `toml:"backends,omitempty"` - Dictionaries map[string]*SetupDictionary `toml:"dictionaries,omitempty"` - Loggers map[string]*SetupLogger `toml:"log_endpoints,omitempty"` - KVStores map[string]*SetupKVStore `toml:"kv_stores,omitempty"` + Backends map[string]*SetupBackend `toml:"backends,omitempty"` + Dictionaries map[string]*SetupDictionary `toml:"dictionaries,omitempty"` + Loggers map[string]*SetupLogger `toml:"log_endpoints,omitempty"` + KVStores map[string]*SetupKVStore `toml:"kv_stores,omitempty"` + SecretStores map[string]*SetupSecretStore `toml:"secret_stores,omitempty"` } // Defined indicates if there is any [setup] configuration in the manifest. @@ -280,6 +281,20 @@ type SetupKVStoreItems struct { Description string `toml:"description,omitempty"` } +// SetupSecretStore represents a '[setup.secret_stores.]' instance. +type SetupSecretStore struct { + Entries map[string]SetupSecretStoreEntry `toml:"entries,omitempty"` + Description string `toml:"description,omitempty"` +} + +// SetupSecretStoreEntry represents a '[setup.secret_stores..entries]' instance. +type SetupSecretStoreEntry struct { + // The secret value is intentionally omitted to avoid secrets + // from being included in the manifest. Instead, secret + // values are input during setup. + Description string `toml:"description,omitempty"` +} + // LocalServer represents a list of mocked Viceroy resources. type LocalServer struct { Backends map[string]LocalBackend `toml:"backends"`