diff --git a/CHANGELOG.md b/CHANGELOG.md index e11856758..0d2ee0fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,6 +163,8 @@ - feat(compute/build): Block version 1.93.0 of Rust to avoid a wasm32-wasip2 bug. ([#1653](https://github.com/fastly/cli/pull/1653)) - feat(service/vcl): escape control characters when displaying VCL content for cleaner terminal output ([#1637](https://github.com/fastly/cli/pull/1637)) +- feat(compute/deploy): Apply \[setup.products] for enabling products during initial deploy or publish ([#1617](https://github.com/fastly/cli/pull/1617)) + ### Dependencies: - build(deps): `golang.org/x/net` from 0.50.0 to 0.51.0 ([#1674](https://github.com/fastly/cli/pull/1674)) diff --git a/pkg/commands/compute/deploy.go b/pkg/commands/compute/deploy.go index 61f8f867d..b87e9fcf1 100644 --- a/pkg/commands/compute/deploy.go +++ b/pkg/commands/compute/deploy.go @@ -725,6 +725,7 @@ type ServiceResources struct { objectStores *setup.KVStores kvStores *setup.KVStores secretStores *setup.SecretStores + products *setup.Products } // ConstructNewServiceResources instantiates multiple [setup] config resources for a @@ -795,6 +796,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 @@ -849,6 +861,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 } @@ -865,6 +884,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{ @@ -921,6 +941,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..16de72883 --- /dev/null +++ b/pkg/commands/compute/setup/products.go @@ -0,0 +1,429 @@ +package setup + +import ( + "context" + "errors" + "fmt" + "io" + "reflect" + "strings" + + "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/v15/fastly" + "github.com/fastly/go-fastly/v15/fastly/products/apidiscovery" + "github.com/fastly/go-fastly/v15/fastly/products/botmanagement" + "github.com/fastly/go-fastly/v15/fastly/products/brotlicompression" + "github.com/fastly/go-fastly/v15/fastly/products/ddosprotection" + "github.com/fastly/go-fastly/v15/fastly/products/domaininspector" + "github.com/fastly/go-fastly/v15/fastly/products/fanout" + "github.com/fastly/go-fastly/v15/fastly/products/imageoptimizer" + "github.com/fastly/go-fastly/v15/fastly/products/logexplorerinsights" + "github.com/fastly/go-fastly/v15/fastly/products/ngwaf" + "github.com/fastly/go-fastly/v15/fastly/products/origininspector" + "github.com/fastly/go-fastly/v15/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) + +type ProductDDoSProtection struct { + Product + Mode string +} + +func NewProductDDoSProtection(mode string) *ProductDDoSProtection { + return &ProductDDoSProtection{ + Product: *NewProductEnabled(), + Mode: mode, + } +} + +var _ ProductSettings = (*ProductDDoSProtection)(nil) + +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(_ io.Writer, p *ProductsMap, _ manifest.SetupProductSettings) error { + p.APIDiscovery = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.APIDiscovery + }, + enable: func(fc *fastly.Client, _ ProductSettings, serviceID string) error { + _, err := apidiscovery.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + { + id: botmanagement.ProductID, + name: botmanagement.ProductName, + getSetupProduct: func(setupProducts *manifest.SetupProducts) manifest.SetupProductSettings { + return setupProducts.BotManagement + }, + configure: func(_ io.Writer, p *ProductsMap, _ manifest.SetupProductSettings) error { + p.BotManagement = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.BotManagement + }, + enable: func(fc *fastly.Client, _ ProductSettings, serviceID string) error { + _, err := botmanagement.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + { + id: brotlicompression.ProductID, + name: brotlicompression.ProductName, + getSetupProduct: func(setupProducts *manifest.SetupProducts) manifest.SetupProductSettings { + return setupProducts.BrotliCompression + }, + configure: func(_ io.Writer, p *ProductsMap, _ manifest.SetupProductSettings) error { + p.BrotliCompression = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.BrotliCompression + }, + enable: func(fc *fastly.Client, _ ProductSettings, serviceID string) error { + _, err := brotlicompression.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + { + id: ddosprotection.ProductID, + name: ddosprotection.ProductName, + getSetupProduct: func(setupProducts *manifest.SetupProducts) manifest.SetupProductSettings { + return setupProducts.DDoSProtection + }, + configure: func(w io.Writer, p *ProductsMap, sp manifest.SetupProductSettings) error { + ddosProtectionSetupProduct, ok := sp.(*manifest.SetupProductDDoSProtection) + if !ok { + return fmt.Errorf("unexpected: Incorrect type for setupProduct") + } + if strings.TrimSpace(ddosProtectionSetupProduct.Mode) == "" { + return fmt.Errorf("mode is required") + } + text.Output(w, " mode: %s", ddosProtectionSetupProduct.Mode) + p.DDoSProtection = NewProductDDoSProtection(ddosProtectionSetupProduct.Mode) + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.DDoSProtection + }, + enable: func(fc *fastly.Client, product ProductSettings, serviceID string) error { + ddosProtectionProduct, ok := product.(*ProductDDoSProtection) + if !ok { + return fmt.Errorf("unexpected: Incorrect type for product") + } + _, err := ddosprotection.Enable(context.TODO(), fc, serviceID, ddosprotection.EnableInput{Mode: ddosProtectionProduct.Mode}) + return err + }, + }, + { + id: domaininspector.ProductID, + name: domaininspector.ProductName, + getSetupProduct: func(setupProducts *manifest.SetupProducts) manifest.SetupProductSettings { + return setupProducts.DomainInspector + }, + configure: func(_ io.Writer, p *ProductsMap, _ manifest.SetupProductSettings) error { + p.DomainInspector = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.DomainInspector + }, + enable: func(fc *fastly.Client, _ ProductSettings, serviceID string) error { + _, err := domaininspector.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + { + id: fanout.ProductID, + name: fanout.ProductName, + getSetupProduct: func(setupProducts *manifest.SetupProducts) manifest.SetupProductSettings { + return setupProducts.Fanout + }, + configure: func(_ io.Writer, p *ProductsMap, _ manifest.SetupProductSettings) error { + p.Fanout = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.Fanout + }, + enable: func(fc *fastly.Client, _ ProductSettings, serviceID string) error { + _, err := fanout.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + { + id: imageoptimizer.ProductID, + name: imageoptimizer.ProductName, + getSetupProduct: func(setupProducts *manifest.SetupProducts) manifest.SetupProductSettings { + return setupProducts.ImageOptimizer + }, + configure: func(_ io.Writer, p *ProductsMap, _ manifest.SetupProductSettings) error { + p.ImageOptimizer = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.ImageOptimizer + }, + enable: func(fc *fastly.Client, _ ProductSettings, serviceID string) error { + _, err := imageoptimizer.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + { + id: logexplorerinsights.ProductID, + name: logexplorerinsights.ProductName, + getSetupProduct: func(setupProducts *manifest.SetupProducts) manifest.SetupProductSettings { + return setupProducts.LogExplorerInsights + }, + configure: func(_ io.Writer, p *ProductsMap, _ manifest.SetupProductSettings) error { + p.LogExplorerInsights = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.LogExplorerInsights + }, + enable: func(fc *fastly.Client, _ ProductSettings, serviceID string) error { + _, err := logexplorerinsights.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + { + id: ngwaf.ProductID, + name: ngwaf.ProductName, + getSetupProduct: func(setupProducts *manifest.SetupProducts) manifest.SetupProductSettings { + return setupProducts.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") + } + if strings.TrimSpace(ngwafSetupProduct.WorkspaceID) == "" { + return fmt.Errorf("workspace_id is required") + } + text.Output(w, " workspace_id: %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(setupProducts *manifest.SetupProducts) manifest.SetupProductSettings { + return setupProducts.OriginInspector + }, + configure: func(_ io.Writer, p *ProductsMap, _ manifest.SetupProductSettings) error { + p.OriginInspector = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.OriginInspector + }, + enable: func(fc *fastly.Client, _ ProductSettings, serviceID string) error { + _, err := origininspector.Enable(context.TODO(), fc, serviceID) + return err + }, + }, + { + id: websockets.ProductID, + name: websockets.ProductName, + getSetupProduct: func(setupProducts *manifest.SetupProducts) manifest.SetupProductSettings { + return setupProducts.WebSockets + }, + configure: func(_ io.Writer, p *ProductsMap, _ manifest.SetupProductSettings) error { + p.WebSockets = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.WebSockets + }, + enable: func(fc *fastly.Client, _ 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.AnyDefined() +} + +// Configure prompts the user for specific values related to the service resource. +func (p *Products) Configure() error { + 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 + } + 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 { + anyEnabled := false + for _, spec := range productsSpecs { + product := normalizeIfacePtr(spec.getConfiguredProduct(&p.required)) + if product != nil && product.Enabled() { + anyEnabled = true + break + } + } + if !anyEnabled { + return nil + } + + 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..68a354b43 --- /dev/null +++ b/pkg/commands/compute/setup/products_create_test.go @@ -0,0 +1,183 @@ +package setup_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/fastly/cli/pkg/commands/compute/setup" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/text" +) + +// TestProductsCreate tests the `Create` method of the `Products` struct. +func TestProductsCreate(t *testing.T) { + scenarios := []struct { + name string + setup *manifest.SetupProducts + client *mock.HTTPClient + wantOutput string + wantError string + expectedRequests []testutil.ExpectedRequest + }{ + { + name: "successfully enables a single product", + setup: &manifest.SetupProducts{ + APIDiscovery: &manifest.SetupProduct{ + Enable: true, + }, + }, + client: mock.NewHTTPClientWithResponses([]*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"api_discovery","name":"API Discovery"}`)), + }, + }), + expectedRequests: []testutil.ExpectedRequest{ + { + Method: http.MethodPut, + Path: "/enabled-products/v1/api_discovery/services/123", + }, + }, + wantOutput: "Enabling product 'api_discovery'...", + }, + { + name: "successfully enables multiple products", + setup: &manifest.SetupProducts{ + APIDiscovery: &manifest.SetupProduct{ + Enable: true, + }, + OriginInspector: &manifest.SetupProduct{ + Enable: true, + }, + }, + client: mock.NewHTTPClientWithResponses([]*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"some_product","name":"Some Product"}`)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"some_product","name":"Some Product"}`)), + }, + }), + expectedRequests: []testutil.ExpectedRequest{ + { + Method: http.MethodPut, + Path: "/enabled-products/v1/api_discovery/services/123", + }, + { + Method: http.MethodPut, + Path: "/enabled-products/v1/origin_inspector/services/123", + }, + }, + }, + { + name: "handles API error when enabling a product", + setup: &manifest.SetupProducts{ + APIDiscovery: &manifest.SetupProduct{ + Enable: true, + }, + }, + client: mock.NewHTTPClientWithErrors([]error{ + testutil.Err, + }), + expectedRequests: []testutil.ExpectedRequest{ + { + Method: http.MethodPut, + Path: "/enabled-products/v1/api_discovery/services/123", + }, + }, + wantError: "error enabling product [api_discovery]: Put \"https://api.example.com/enabled-products/v1/api_discovery/services/123\": test error", + }, + { + name: "no API calls when no products are configured", + setup: &manifest.SetupProducts{}, + client: mock.NewHTTPClientWithResponses([]*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"some_product","name":"Some Product"}`)), + }, + }), + }, + { + name: "successfully enables ngwaf with workspace id", + setup: &manifest.SetupProducts{ + NGWAF: &manifest.SetupProductNGWAF{ + SetupProduct: manifest.SetupProduct{ + Enable: true, + }, + WorkspaceID: "w-123", + }, + }, + client: mock.NewHTTPClientWithResponses([]*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"ngwaf","name":"Next-Gen WAF"}`)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"some_product","name":"Some Product"}`)), + }, + }), + wantOutput: "Enabling product 'ngwaf'...", + expectedRequests: []testutil.ExpectedRequest{ + { + Method: http.MethodPut, + Path: "/enabled-products/v1/ngwaf/services/123", + WantJSON: testutil.StrPtr("{\"workspace_id\":\"w-123\"}"), + }, + }, + }, + } + + for _, testcase := range scenarios { + t.Run(testcase.name, func(t *testing.T) { + apiClient, err := mock.NewFastlyClient(testcase.client) + if err != nil { + t.Fatal(fmt.Errorf("failed to mock fastly.client: %w", err)) + } + + var out bytes.Buffer + spinner, err := text.NewSpinner(&out) + if err != nil { + t.Fatal(err) + } + + products := setup.Products{ + APIClient: apiClient, + ServiceID: "123", + Spinner: spinner, + Stdout: &out, + Setup: testcase.setup, + } + + err = products.Configure() + testutil.AssertNoError(t, err) + + err = products.Create() + + if testcase.wantError != "" { + testutil.AssertErrorContains(t, err, testcase.wantError) + } else { + testutil.AssertNoError(t, err) + if testcase.wantOutput != "" { + testutil.AssertStringContains(t, out.String(), testcase.wantOutput) + } + } + + if len(testcase.expectedRequests) != len(testcase.client.Requests) { + t.Errorf("expected %d API calls, but got %d", len(testcase.expectedRequests), len(testcase.client.Requests)) + } + + for i, expectedRequest := range testcase.expectedRequests { + testutil.AssertRequest(t, &testcase.client.Requests[i], expectedRequest) + } + }) + } +} diff --git a/pkg/manifest/manifest_test.go b/pkg/manifest/manifest_test.go index 83bdc9cea..bd539bbaf 100644 --- a/pkg/manifest/manifest_test.go +++ b/pkg/manifest/manifest_test.go @@ -233,9 +233,8 @@ func TestDataServiceID(t *testing.T) { } } -// This test validates that manually added changes, such as the toml -// syntax for Viceroy local testing, are not accidentally deleted after -// decoding and encoding flows. +// This test validates that manually added changes to the toml syntax for +// Viceroy local testing, are not accidentally deleted after decoding and encoding flows. func TestManifestPersistsLocalServerSection(t *testing.T) { fpath := filepath.Join("./", "testdata", "fastly-viceroy-update.toml") @@ -305,7 +304,82 @@ func TestManifestPersistsLocalServerSection(t *testing.T) { } want, got := originalTree.String(), localTree.String() if diff := cmp.Diff(want, got); diff != "" { - t.Fatalf("testing section between original and updated fastly.toml do not match (-want +got):\n%s", diff) + t.Fatalf("testing [local_server] section between original and updated fastly.toml do not match (-want +got):\n%s", diff) + } +} + +// This test validates that manually added changes to the toml syntax for +// setup (initial deploy or publish), are not accidentally deleted after decoding and encoding flows. +func TestManifestPersistsSetupSection(t *testing.T) { + fpath := filepath.Join("./", "testdata", "fastly-viceroy-update.toml") + + b, err := os.ReadFile(fpath) + if err != nil { + t.Fatal(err) + } + + defer func(fpath string, b []byte) { + err := os.WriteFile(fpath, b, 0o600) + if err != nil { + t.Fatal(err) + } + }(fpath, b) + + original, err := toml.LoadFile(fpath) + if err != nil { + t.Fatal(err) + } + + ot := original.Get("setup") + if ot == nil { + t.Fatal("expected [setup] block to exist in fastly.toml but is missing") + } + + osid := original.Get("service_id") + if osid != nil { + t.Fatal("did not expect service_id key to exist in fastly.toml but is present") + } + + var m manifest.File + + err = m.Read(fpath) + if err != nil { + t.Fatal(err) + } + + m.ServiceID = "a change occurred to the data structure" + + err = m.Write(fpath) + if err != nil { + t.Fatal(err) + } + + latest, err := toml.LoadFile(fpath) + if err != nil { + t.Fatal(err) + } + + lsid := latest.Get("service_id") + if lsid == nil { + t.Fatal("expected service_id key to exist in fastly.toml but is missing") + } + + lt := latest.Get("setup") + if lt == nil { + t.Fatal("expected [setup] block to exist in fastly.toml but is missing") + } + + localTree, ok := lt.(*toml.Tree) + if !ok { + t.Fatal("failed to convert 'local' interface{} to toml.Tree") + } + originalTree, ok := ot.(*toml.Tree) + if !ok { + t.Fatal("failed to convert 'original' interface{} to toml.Tree") + } + want, got := originalTree.String(), localTree.String() + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("testing [setup] section between original and updated fastly.toml do not match (-want +got):\n%s", diff) } } diff --git a/pkg/manifest/setup.go b/pkg/manifest/setup.go index 1916a5405..f6fd7ae09 100644 --- a/pkg/manifest/setup.go +++ b/pkg/manifest/setup.go @@ -9,6 +9,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. @@ -33,7 +34,9 @@ func (s Setup) Defined() bool { if len(s.SecretStores) > 0 { defined = true } - + if s.Products != nil && s.Products.AnyDefined() { + defined = true + } return defined } @@ -88,3 +91,59 @@ 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 *SetupProductDDoSProtection `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"` +} + +func (p *SetupProducts) AnyDefined() bool { + return p != nil && (p.APIDiscovery != nil || + p.BotManagement != nil || + p.BrotliCompression != nil || + p.DDoSProtection != nil || + p.DomainInspector != nil || + p.Fanout != nil || + p.ImageOptimizer != nil || + p.LogExplorerInsights != nil || + p.NGWAF != nil || + p.OriginInspector != nil || + p.WebSockets != nil) +} + +type SetupProductSettings interface { + Enabled() bool +} + +type SetupProduct struct { + Enable bool `toml:"enable,omitempty"` +} + +var _ SetupProductSettings = (*SetupProduct)(nil) + +func (p *SetupProduct) Enabled() bool { + return p != nil && p.Enable +} + +type SetupProductNGWAF struct { + SetupProduct + WorkspaceID string `toml:"workspace_id,omitempty"` +} + +var _ SetupProductSettings = (*SetupProductNGWAF)(nil) + +type SetupProductDDoSProtection struct { + SetupProduct + Mode string `toml:"mode,omitempty"` +} + +var _ SetupProductSettings = (*SetupProductDDoSProtection)(nil) diff --git a/pkg/manifest/testdata/fastly-viceroy-update.toml b/pkg/manifest/testdata/fastly-viceroy-update.toml index 017a33869..3a561c8c7 100644 --- a/pkg/manifest/testdata/fastly-viceroy-update.toml +++ b/pkg/manifest/testdata/fastly-viceroy-update.toml @@ -75,3 +75,14 @@ file = "/path/to/other/secret.json" [[local_server.secret_stores.store_two]] key = "fourth" env = "ENV_FOURTH" + +[setup] +[setup.products] +[setup.products.ddos_protection] +enable = true +mode = "log" +[setup.products.fanout] +enable = true +[setup.products.ngwaf] +enable = true +workspace_id = "7JFbo4RNA0OKdFWC04r6B3" diff --git a/pkg/mock/client.go b/pkg/mock/client.go index 429869865..f29dbd70e 100644 --- a/pkg/mock/client.go +++ b/pkg/mock/client.go @@ -58,6 +58,37 @@ func (c *HTTPClient) Do(r *http.Request) (*http.Response, error) { return c.Responses[c.Index], c.Errors[c.Index] } +// NewHTTPClient returns a mock HTTP Client that returns stubbed responses and errors. +func NewHTTPClient(res []*http.Response, err []error, saveRequests bool) *HTTPClient { + if len(res) != len(err) { + panic("mock.HTTPClient: Responses and Errors length mismatch") + } + return &HTTPClient{ + Index: -1, + Responses: res, + Errors: err, + SaveRequests: saveRequests, + } +} + +// NewHTTPClientDefault returns a mock HTTP Client that returns stubbed responses and +// errors, and saves requests. +func NewHTTPClientDefault(res []*http.Response, err []error) *HTTPClient { + return NewHTTPClient(res, err, true) +} + +// NewHTTPClientWithResponses returns a mock HTTP Client that returns stubbed response and +// no errors, and saves requests. +func NewHTTPClientWithResponses(res []*http.Response) *HTTPClient { + return NewHTTPClientDefault(res, make([]error, len(res))) +} + +// NewHTTPClientWithErrors returns a mock HTTP Client that returns errors with no responses, +// and saves requests. +func NewHTTPClientWithErrors(err []error) *HTTPClient { + return NewHTTPClientDefault(make([]*http.Response, len(err)), err) +} + // HTMLClient returns a mock HTTP Client that returns a stubbed response or // error. func HTMLClient(res []*http.Response, err []error) api.HTTPClient { @@ -85,3 +116,10 @@ func NewHTTPResponse(statusCode int, headers map[string]string, body io.ReadClos Header: h, } } + +func NewNetHTTPClientWithMockHTTPClient(httpClient *HTTPClient) *http.Client { + netHTTPClient := &http.Client{ + Transport: NewRoundTripper(httpClient), + } + return netHTTPClient +} diff --git a/pkg/mock/fastly_client.go b/pkg/mock/fastly_client.go new file mode 100644 index 000000000..da910c59a --- /dev/null +++ b/pkg/mock/fastly_client.go @@ -0,0 +1,16 @@ +package mock + +import ( + "github.com/fastly/go-fastly/v15/fastly" +) + +func NewFastlyClient(httpClient *HTTPClient) (*fastly.Client, error) { + apiClient, err := fastly.NewClientForEndpoint("no-key", "https://api.example.com/") + if err != nil { + return nil, err + } + + apiClient.HTTPClient = NewNetHTTPClientWithMockHTTPClient(httpClient) + + return apiClient, nil +} diff --git a/pkg/mock/round_tripper.go b/pkg/mock/round_tripper.go new file mode 100644 index 000000000..f6cc3f74b --- /dev/null +++ b/pkg/mock/round_tripper.go @@ -0,0 +1,44 @@ +package mock + +import "net/http" + +type RoundTripper struct { + Client *HTTPClient +} + +var _ http.RoundTripper = (*RoundTripper)(nil) + +func NewRoundTripper(c *HTTPClient) *RoundTripper { + return &RoundTripper{Client: c} +} + +func (t *RoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { + if t.Client == nil { + return nil, &ErrMockMisconfigured{Msg: "mock.RoundTripper: Client is nil"} + } + + // Use Client's Do() behavior + resp, err := t.Client.Do(r) + if err != nil { + return nil, err + } + + // Be defensive: avoid returning a nil response when err == nil. + if resp == nil { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: http.NoBody, + Request: r, + }, nil + } + if resp.Request == nil { + resp.Request = r + } + + return resp, nil +} + +type ErrMockMisconfigured struct{ Msg string } + +func (e *ErrMockMisconfigured) Error() string { return e.Msg } diff --git a/pkg/testutil/http.go b/pkg/testutil/http.go new file mode 100644 index 000000000..0dc038dc4 --- /dev/null +++ b/pkg/testutil/http.go @@ -0,0 +1,87 @@ +package testutil + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "reflect" + "strings" + "testing" +) + +type ExpectedRequest struct { + Method string + Path string + + // Body: nil means “don’t care”. + // If non-nil: + // - empty string means “expect empty body” + // - otherwise compare as JSON or raw (see below) + WantJSON *string + + // Header assertions: + RequireHeaders http.Header // headers that must be present (value matching rules below) + ForbidHeaders []string // header names that must NOT be present +} + +// AssertRequest asserts on the request's properties. If WantJSON has a value then +// the body of the Request will be consumed. +func AssertRequest(t *testing.T, got *http.Request, exp ExpectedRequest) { + t.Helper() + + if got.Method != exp.Method { + t.Fatalf("method got %q want %q", got.Method, exp.Method) + } + if got.URL.Path != exp.Path { + t.Fatalf("path got %q want %q", got.URL.Path, exp.Path) + } + + // Headers (require/forbid only) + for _, k := range exp.ForbidHeaders { + ck := http.CanonicalHeaderKey(k) + if _, ok := got.Header[ck]; ok { + t.Fatalf("header %q must not be present", k) + } + } + for k, wantVals := range exp.RequireHeaders { + ck := http.CanonicalHeaderKey(k) + gotVals, ok := got.Header[ck] + if !ok { + t.Fatalf("missing required header %q", k) + } + if len(wantVals) == 0 { + continue // presence-only + } + if !reflect.DeepEqual(gotVals, wantVals) { + t.Fatalf("header %q got %v want %v", k, gotVals, wantVals) + } + } + + // Body (JSON semantic compare) + if exp.WantJSON != nil { + gotBody, err := io.ReadAll(got.Body) + if err != nil { + t.Fatalf("can't read body") + } + gotTrim := bytes.TrimSpace(gotBody) + if len(strings.TrimSpace(*exp.WantJSON)) == 0 { + if len(gotTrim) != 0 { + t.Fatalf("expected empty body, got %q", string(gotBody)) + } + return + } + + var gv any + var ev any + if err := json.Unmarshal(gotTrim, &gv); err != nil { + t.Fatalf("got body not valid JSON: %v; body=%q", err, string(gotBody)) + } + if err := json.Unmarshal([]byte(*exp.WantJSON), &ev); err != nil { + t.Fatalf("expected JSON not valid: %v; json=%q", err, *exp.WantJSON) + } + if !reflect.DeepEqual(gv, ev) { + t.Fatalf("JSON body mismatch\n got: %s\nwant: %s", string(gotBody), *exp.WantJSON) + } + } +} diff --git a/pkg/testutil/string.go b/pkg/testutil/string.go index 2e23a8136..e0d8d7b11 100644 --- a/pkg/testutil/string.go +++ b/pkg/testutil/string.go @@ -6,3 +6,6 @@ import "strings" func StripNewLines(s string) string { return strings.ReplaceAll(s, "\n", "") } + +// StrPtr is used to obtain the address of a literal string. +func StrPtr(s string) *string { return &s }