diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fc0ba451..186a23469 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - feat(compute/hashfiles): remove hashsum subcommand ([#1608](https://github.com/fastly/cli/pull/1608)) - feat(commands/ngwaf/rules): add support for CRUD operations for NGWAF rules ([#1578](https://github.com/fastly/cli/pull/1605)) - feat(compute/deploy): added the `--no-default-domain` flag to allow for the skipping of automatic domain creation when deploying a Compute service([#1610](https://github.com/fastly/cli/pull/1610)) +- feat(compute/deploy): Apply \[setup.products] for enabling products during initial deploy ([#1617](https://github.com/fastly/cli/pull/1617)) ### Bug fixes: diff --git a/pkg/commands/compute/deploy.go b/pkg/commands/compute/deploy.go index 773950951..961118cdd 100644 --- a/pkg/commands/compute/deploy.go +++ b/pkg/commands/compute/deploy.go @@ -817,6 +817,7 @@ type ServiceResources struct { objectStores *setup.KVStores kvStores *setup.KVStores secretStores *setup.SecretStores + products *setup.Products } // ConstructNewServiceResources instantiates multiple [setup] config resources for a @@ -887,6 +888,17 @@ func (c *DeployCommand) ConstructNewServiceResources( Stdin: in, Stdout: out, } + + sr.products = &setup.Products{ + APIClient: c.Globals.APIClient, + AcceptDefaults: c.Globals.Flags.AcceptDefaults, + NonInteractive: c.Globals.Flags.NonInteractive, + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Setup: c.Globals.Manifest.File.Setup.Products, + Stdin: in, + Stdout: out, + } } // ConfigureServiceResources calls the .Predefined() and .Configure() methods @@ -941,6 +953,13 @@ func (c *DeployCommand) ConfigureServiceResources(sr ServiceResources, serviceID } } + if sr.products.Predefined() { + if err := sr.products.Configure(); err != nil { + errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) + return fmt.Errorf("error configuring service products: %w", err) + } + } + return nil } @@ -957,6 +976,7 @@ func (c *DeployCommand) CreateServiceResources( sr.objectStores.Spinner = spinner sr.kvStores.Spinner = spinner sr.secretStores.Spinner = spinner + sr.products.Spinner = spinner if err := sr.backends.Create(); err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ @@ -1013,6 +1033,17 @@ func (c *DeployCommand) CreateServiceResources( return err } + if err := sr.products.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 + } + return nil } diff --git a/pkg/commands/compute/setup/products.go b/pkg/commands/compute/setup/products.go new file mode 100644 index 000000000..7f1eb40bc --- /dev/null +++ b/pkg/commands/compute/setup/products.go @@ -0,0 +1,402 @@ +package setup + +import ( + "context" + "errors" + "fmt" + "io" + "reflect" + + "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/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/products/apidiscovery" + "github.com/fastly/go-fastly/v12/fastly/products/botmanagement" + "github.com/fastly/go-fastly/v12/fastly/products/brotlicompression" + "github.com/fastly/go-fastly/v12/fastly/products/ddosprotection" + "github.com/fastly/go-fastly/v12/fastly/products/domaininspector" + "github.com/fastly/go-fastly/v12/fastly/products/fanout" + "github.com/fastly/go-fastly/v12/fastly/products/imageoptimizer" + "github.com/fastly/go-fastly/v12/fastly/products/logexplorerinsights" + "github.com/fastly/go-fastly/v12/fastly/products/ngwaf" + "github.com/fastly/go-fastly/v12/fastly/products/origininspector" + "github.com/fastly/go-fastly/v12/fastly/products/websockets" +) + +// Products represents the service state related to Products defined +// within the fastly.toml [setup] configuration. +// +// NOTE: It implements the setup.Interface interface. +type Products struct { + // Public + APIClient api.Interface + AcceptDefaults bool + NonInteractive bool + Spinner text.Spinner + ServiceID string + ServiceVersion int + Setup *manifest.SetupProducts + Stdin io.Reader + Stdout io.Writer + + // Private + required ProductsMap +} + +// ProductsMap represents the configuration parameters for enabling specified products +// for a service +type ProductsMap struct { + APIDiscovery ProductSettings + BotManagement ProductSettings + BrotliCompression ProductSettings + DdosProtection ProductSettings + DomainInspector ProductSettings + Fanout ProductSettings + ImageOptimizer ProductSettings + LogExplorerInsights ProductSettings + Ngwaf ProductSettings + OriginInspector ProductSettings + WebSockets ProductSettings +} + +type ProductSettings interface { + Enabled() bool +} + +type Product struct { + Enable bool +} + +func NewProductEnabled() *Product { + return &Product{Enable: true} +} + +var _ ProductSettings = (*Product)(nil) + +func (p *Product) Enabled() bool { + return p != nil && p.Enable +} + +type ProductNgwaf struct { + Product + WorkspaceID string +} + +func NewProductNgWaf(workspaceID string) *ProductNgwaf { + return &ProductNgwaf{ + Product: *NewProductEnabled(), + WorkspaceID: workspaceID, + } +} + +var _ ProductSettings = (*ProductNgwaf)(nil) + +func (p *ProductNgwaf) Enabled() bool { + if p == nil { + return false + } + return p.Product.Enabled() +} + +type productsSpec struct { + id string + name string + getSetupProduct func(*manifest.SetupProducts) manifest.SetupProductSettings + configure func(io.Writer, *ProductsMap, manifest.SetupProductSettings) error + getConfiguredProduct func(*ProductsMap) ProductSettings + enable func(*fastly.Client, ProductSettings, string) error +} + +var productsSpecs []productsSpec + +func init() { + productsSpecs = []productsSpec{ + { + id: apidiscovery.ProductID, + name: apidiscovery.ProductName, + getSetupProduct: func(setupProducts *manifest.SetupProducts) manifest.SetupProductSettings { + return setupProducts.APIDiscovery + }, + configure: func(w io.Writer, p *ProductsMap, sp manifest.SetupProductSettings) error { + p.APIDiscovery = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.APIDiscovery + }, + enable: func(fc *fastly.Client, product ProductSettings, serviceID string) error { + _, err := apidiscovery.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + { + id: botmanagement.ProductID, + name: botmanagement.ProductName, + getSetupProduct: func(products *manifest.SetupProducts) manifest.SetupProductSettings { + return products.BotManagement + }, + configure: func(w io.Writer, p *ProductsMap, sp manifest.SetupProductSettings) error { + p.BotManagement = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.BotManagement + }, + enable: func(fc *fastly.Client, product ProductSettings, serviceID string) error { + _, err := botmanagement.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + { + id: brotlicompression.ProductID, + name: brotlicompression.ProductName, + getSetupProduct: func(products *manifest.SetupProducts) manifest.SetupProductSettings { + return products.BrotliCompression + }, + configure: func(w io.Writer, p *ProductsMap, sp manifest.SetupProductSettings) error { + p.BrotliCompression = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.BrotliCompression + }, + enable: func(fc *fastly.Client, product ProductSettings, serviceID string) error { + _, err := brotlicompression.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + { + id: ddosprotection.ProductID, + name: ddosprotection.ProductName, + getSetupProduct: func(products *manifest.SetupProducts) manifest.SetupProductSettings { + return products.DdosProtection + }, + configure: func(w io.Writer, p *ProductsMap, sp manifest.SetupProductSettings) error { + p.DdosProtection = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.DdosProtection + }, + enable: func(fc *fastly.Client, product ProductSettings, serviceID string) error { + _, err := ddosprotection.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + { + id: domaininspector.ProductID, + name: domaininspector.ProductName, + getSetupProduct: func(products *manifest.SetupProducts) manifest.SetupProductSettings { + return products.DomainInspector + }, + configure: func(w io.Writer, p *ProductsMap, sp manifest.SetupProductSettings) error { + p.DomainInspector = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.DomainInspector + }, + enable: func(fc *fastly.Client, product ProductSettings, serviceID string) error { + _, err := domaininspector.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + { + id: fanout.ProductID, + name: fanout.ProductName, + getSetupProduct: func(products *manifest.SetupProducts) manifest.SetupProductSettings { + return products.Fanout + }, + configure: func(w io.Writer, p *ProductsMap, sp manifest.SetupProductSettings) error { + p.Fanout = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.Fanout + }, + enable: func(fc *fastly.Client, product ProductSettings, serviceID string) error { + _, err := fanout.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + { + id: imageoptimizer.ProductID, + name: imageoptimizer.ProductName, + getSetupProduct: func(products *manifest.SetupProducts) manifest.SetupProductSettings { + return products.ImageOptimizer + }, + configure: func(w io.Writer, p *ProductsMap, sp manifest.SetupProductSettings) error { + p.ImageOptimizer = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.ImageOptimizer + }, + enable: func(fc *fastly.Client, product ProductSettings, serviceID string) error { + _, err := imageoptimizer.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + { + id: logexplorerinsights.ProductID, + name: logexplorerinsights.ProductName, + getSetupProduct: func(products *manifest.SetupProducts) manifest.SetupProductSettings { + return products.LogExplorerInsights + }, + configure: func(w io.Writer, p *ProductsMap, sp manifest.SetupProductSettings) error { + p.LogExplorerInsights = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.LogExplorerInsights + }, + enable: func(fc *fastly.Client, product ProductSettings, serviceID string) error { + _, err := logexplorerinsights.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + { + id: ngwaf.ProductID, + name: ngwaf.ProductName, + getSetupProduct: func(products *manifest.SetupProducts) manifest.SetupProductSettings { + return products.Ngwaf + }, + configure: func(w io.Writer, p *ProductsMap, sp manifest.SetupProductSettings) error { + ngwafSetupProduct, ok := sp.(*manifest.SetupProductNgwaf) + if !ok { + return fmt.Errorf("unexpected: Incorrect type for setupProduct") + } + text.Output(w, " %s", ngwafSetupProduct.WorkspaceID) + p.Ngwaf = NewProductNgWaf(ngwafSetupProduct.WorkspaceID) + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.Ngwaf + }, + enable: func(fc *fastly.Client, product ProductSettings, serviceID string) error { + ngwafProduct, ok := product.(*ProductNgwaf) + if !ok { + return fmt.Errorf("unexpected: Incorrect type for product") + } + _, err := ngwaf.Enable(context.TODO(), fc, serviceID, ngwaf.EnableInput{WorkspaceID: ngwafProduct.WorkspaceID}) + return err + }, + }, + { + id: origininspector.ProductID, + name: origininspector.ProductName, + getSetupProduct: func(products *manifest.SetupProducts) manifest.SetupProductSettings { + return products.OriginInspector + }, + configure: func(w io.Writer, p *ProductsMap, sp manifest.SetupProductSettings) error { + p.OriginInspector = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.OriginInspector + }, + enable: func(fc *fastly.Client, product ProductSettings, serviceID string) error { + _, err := origininspector.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + { + id: websockets.ProductID, + name: websockets.ProductName, + getSetupProduct: func(products *manifest.SetupProducts) manifest.SetupProductSettings { + return products.WebSockets + }, + configure: func(w io.Writer, p *ProductsMap, sp manifest.SetupProductSettings) error { + p.WebSockets = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.WebSockets + }, + enable: func(fc *fastly.Client, product ProductSettings, serviceID string) error { + _, err := websockets.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + } +} + +// Predefined indicates if the service resource has been specified within the +// fastly.toml file using a [setup] configuration block. +func (p *Products) Predefined() bool { + return p != nil && p.Setup != nil && p.Setup.AnyEnabled() +} + +// Configure prompts the user for specific values related to the service resource. +func (p *Products) Configure() error { + if !p.Predefined() { + return nil + } + + text.Info(p.Stdout, "The package code will attempt to enable the following products on the service.\n") + + for _, spec := range productsSpecs { + product := normalizeIfacePtr(spec.getSetupProduct(p.Setup)) + if product == nil || !product.Enabled() { + continue + } + if err := product.Validate(); err != nil { + return fmt.Errorf("%s: %w", "setup.products."+spec.id, err) + } + text.Output(p.Stdout, "%s", text.Bold(spec.name)) + if err := spec.configure(p.Stdout, &p.required, product); err != nil { + return fmt.Errorf("%s: %w", "setup.products."+spec.id, err) + } + } + + return nil +} + +// Create calls the relevant API to create the service resource(s). +func (p *Products) Create() error { + if p.Spinner == nil { + return fsterrors.RemediationError{ + Inner: fmt.Errorf("internal logic error: no spinner configured for setup.Products"), + Remediation: fsterrors.BugRemediation, + } + } + + fc, ok := p.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + for _, spec := range productsSpecs { + product := normalizeIfacePtr(spec.getConfiguredProduct(&p.required)) + if product == nil || !product.Enabled() { + continue + } + err := p.Spinner.Process( + fmt.Sprintf("Enabling product '%s'...", spec.id), + func(_ *text.SpinnerWrapper) error { + if err := spec.enable(fc, product, p.ServiceID); err != nil { + return fmt.Errorf("error enabling product [%s]: %w", spec.id, err) + } + return nil + }, + ) + + if err != nil { + return err + } + } + return nil +} + +// normalizeIfacePtr converts an interface holding a typed-nil pointer into a real nil interface. +// Works for any interface type parameter I. +func normalizeIfacePtr[I any](v I) I { + rv := reflect.ValueOf(v) + if !rv.IsValid() || (rv.Kind() == reflect.Ptr && rv.IsNil()) { + var zero I + return zero + } + return v +} diff --git a/pkg/commands/compute/setup/products_create_test.go b/pkg/commands/compute/setup/products_create_test.go new file mode 100644 index 000000000..54e3715c9 --- /dev/null +++ b/pkg/commands/compute/setup/products_create_test.go @@ -0,0 +1,165 @@ +package setup_test + +import ( + "bytes" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/fastly/cli/pkg/commands/compute/setup" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v12/fastly" +) + +// TestProductsCreate tests the `Create` method of the `Products` struct. +func TestProductsCreate(t *testing.T) { + scenarios := []struct { + name string + setup *manifest.SetupProducts + mockHTTP *mockHTTPClient + expectedError string + expectedOutput string + expectedAPIPaths []string + }{ + { + name: "successfully enables a single product", + setup: &manifest.SetupProducts{ + APIDiscovery: &manifest.SetupProduct{ + Enable: true, + }, + }, + mockHTTP: &mockHTTPClient{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"api_discovery","name":"API Discovery"}`)), + }, + }, + expectedOutput: "Enabling product 'api_discovery'...", + expectedAPIPaths: []string{"/service/123/product/api_discovery/enable"}, + }, + { + name: "successfully enables multiple products", + setup: &manifest.SetupProducts{ + APIDiscovery: &manifest.SetupProduct{ + Enable: true, + }, + OriginInspector: &manifest.SetupProduct{ + Enable: true, + }, + }, + mockHTTP: &mockHTTPClient{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"some_product","name":"Some Product"}`)), + }, + }, + expectedAPIPaths: []string{ + "/service/123/product/api_discovery/enable", + "/service/123/product/origin_inspector/enable", + }, + }, + { + name: "handles API error when enabling a product", + setup: &manifest.SetupProducts{ + APIDiscovery: &manifest.SetupProduct{ + Enable: true, + }, + }, + mockHTTP: &mockHTTPClient{ + err: errors.New("api error"), + }, + expectedError: "error enabling product [api_discovery]: api error", + }, + { + name: "no API calls when no products are configured", + setup: &manifest.SetupProducts{}, + mockHTTP: &mockHTTPClient{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"some_product","name":"Some Product"}`)), + }, + }, + expectedAPIPaths: []string{}, + }, + { + name: "successfully enables ngwaf with workspace id", + setup: &manifest.SetupProducts{ + Ngwaf: &manifest.SetupProductNgwaf{ + SetupProduct: manifest.SetupProduct{ + Enable: true, + }, + WorkspaceID: "w-123", + }, + }, + mockHTTP: &mockHTTPClient{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"ngwaf","name":"Next-Gen WAF"}`)), + }, + }, + expectedOutput: "Enabling product 'ngwaf'...", + expectedAPIPaths: []string{"/service/123/product/ngwaf/enable"}, + }, + } + + for _, testcase := range scenarios { + t.Run(testcase.name, func(t *testing.T) { + apiClient, err := fastly.NewClient(testcase.mockHTTP) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + products := setup.Products{ + APIClient: apiClient, + ServiceID: "123", + Spinner: testutil.NewSpinner(&out), + Stdout: &out, + Setup: testcase.setup, + } + + err = products.Configure() + testutil.AssertNoError(t, err) + + err = products.Create() + + if testcase.expectedError != "" { + testutil.AssertErrorContains(t, err, testcase.expectedError) + } else { + testutil.AssertNoError(t, err) + if testcase.expectedOutput != "" { + testutil.AssertStringContains(t, out.String(), testcase.expectedOutput) + } + } + + if len(testcase.expectedAPIPaths) != len(testcase.mockHTTP.called) { + t.Errorf("expected %d API calls, but got %d", len(testcase.expectedAPIPaths), len(testcase.mockHTTP.called)) + } + + for i, path := range testcase.expectedAPIPaths { + if i >= len(testcase.mockHTTP.called) { + t.Errorf("expected API path %s to be called, but it was not", path) + continue + } + testutil.AssertStringContains(t, testcase.mockHTTP.called[i], path) + } + }) + } +} + +type mockHTTPClient struct { + response *http.Response + err error + called []string +} + +func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) { + m.called = append(m.called, req.URL.Path) + if m.err != nil { + return nil, m.err + } + return m.response, nil +} diff --git a/pkg/commands/compute/setup/products_test.go b/pkg/commands/compute/setup/products_test.go new file mode 100644 index 000000000..101bf5d93 --- /dev/null +++ b/pkg/commands/compute/setup/products_test.go @@ -0,0 +1,23 @@ +package setup_test + +// +//import ( +// "testing" +// +// "github.com/fastly/cli/pkg/commands/compute/setup" +//) +// +//func TestProductsCreate(t *testing.T) { +// +// products := &setup.Products{ +// APIClient: c.Globals.APIClient, +// AcceptDefaults: false, +// NonInteractive: false, +// ServiceID: "serviceID", +// ServiceVersion: 0, +// Setup: , +// Stdin: , +// Stdout: , +// } +// +//} diff --git a/pkg/manifest/setup.go b/pkg/manifest/setup.go index 97e8eca4d..f2ae8be6b 100644 --- a/pkg/manifest/setup.go +++ b/pkg/manifest/setup.go @@ -1,5 +1,10 @@ package manifest +import ( + "fmt" + "strings" +) + // Setup represents a set of service configuration that works with the code in // the package. See https://www.fastly.com/documentation/reference/compute/fastly-toml. type Setup struct { @@ -9,6 +14,7 @@ type Setup struct { ObjectStores map[string]*SetupKVStore `toml:"object_stores,omitempty"` KVStores map[string]*SetupKVStore `toml:"kv_stores,omitempty"` SecretStores map[string]*SetupSecretStore `toml:"secret_stores,omitempty"` + Products *SetupProducts `toml:"products,omitempty"` } // Defined indicates if there is any [setup] configuration in the manifest. @@ -24,10 +30,18 @@ func (s Setup) Defined() bool { if len(s.Loggers) > 0 { defined = true } + if len(s.ObjectStores) > 0 { + defined = true + } if len(s.KVStores) > 0 { defined = true } - + if len(s.SecretStores) > 0 { + defined = true + } + if s.Products != nil && s.Products.AnyEnabled() { + defined = true + } return defined } @@ -81,3 +95,89 @@ type SetupSecretStoreEntry struct { // values are input during setup. Description string `toml:"description,omitempty"` } + +type SetupProducts struct { + APIDiscovery *SetupProduct `toml:"api_discovery,omitempty"` + BotManagement *SetupProduct `toml:"bot_management,omitempty"` + BrotliCompression *SetupProduct `toml:"brotli_compression,omitempty"` + DdosProtection *SetupProduct `toml:"ddos_protection,omitempty"` + DomainInspector *SetupProduct `toml:"domain_inspector,omitempty"` + Fanout *SetupProduct `toml:"fanout,omitempty"` + ImageOptimizer *SetupProduct `toml:"image_optimizer,omitempty"` + LogExplorerInsights *SetupProduct `toml:"log_explorer_insights,omitempty"` + Ngwaf *SetupProductNgwaf `toml:"ngwaf,omitempty"` + OriginInspector *SetupProduct `toml:"origin_inspector,omitempty"` + WebSockets *SetupProduct `toml:"websockets,omitempty"` +} + +type SetupProductSettings interface { + Enabled() bool + Validate() error +} + +type SetupProduct struct { + Enable bool `toml:"enable,omitempty"` +} + +var _ SetupProductSettings = (*SetupProduct)(nil) + +func (p *SetupProduct) Enabled() bool { + return p != nil && p.Enable +} + +func (p *SetupProduct) Validate() error { + return nil +} + +type SetupProductNgwaf struct { + SetupProduct + WorkspaceID string `toml:"workspace_id,omitempty"` +} + +var _ SetupProductSettings = (*SetupProductNgwaf)(nil) + +func (p *SetupProductNgwaf) Enabled() bool { + if p == nil { + return false + } + return p.SetupProduct.Enabled() +} + +func (p *SetupProductNgwaf) Validate() error { + if p == nil || !p.Enable { + return nil + } + if strings.TrimSpace(p.WorkspaceID) == "" { + return fmt.Errorf("workspace_id is required when enable = true") + } + return nil +} + +func (p *SetupProducts) AllSettings() []SetupProductSettings { + if p == nil { + return nil + } + + return []SetupProductSettings{ + p.APIDiscovery, + p.BotManagement, + p.BrotliCompression, + p.DdosProtection, + p.DomainInspector, + p.Fanout, + p.ImageOptimizer, + p.LogExplorerInsights, + p.Ngwaf, + p.OriginInspector, + p.WebSockets, + } +} + +func (p *SetupProducts) AnyEnabled() bool { + for _, s := range p.AllSettings() { + if s != nil && s.Enabled() { + return true + } + } + return false +} diff --git a/pkg/manifest/testdata/fastly-viceroy-update.toml b/pkg/manifest/testdata/fastly-viceroy-update.toml index 017a33869..b95e3785c 100644 --- a/pkg/manifest/testdata/fastly-viceroy-update.toml +++ b/pkg/manifest/testdata/fastly-viceroy-update.toml @@ -75,3 +75,8 @@ file = "/path/to/other/secret.json" [[local_server.secret_stores.store_two]] key = "fourth" env = "ENV_FOURTH" + +[setup] +[setup.products] +[setup.products.fanout] +enable = true