diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f33df03e..73169a936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ - feat(stats): add `stats domain-inspector` subcommand for domain-level metrics. [#1678](https://github.com/fastly/cli/pull/1678) - feat(stats): add `stats origin-inspector` subcommand for origin-level metrics. [#1678](https://github.com/fastly/cli/pull/1678) +- feat(compute/deploy): Apply \[setup.products] for enabling products during initial deploy ([#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)) - build(deps): `actions/upload-artifact` from 6 to 7 ([#1675](https://github.com/fastly/cli/pull/1675)) diff --git a/pkg/commands/compute/deploy.go b/pkg/commands/compute/deploy.go index 30a998cd7..1ce1fe5f0 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..661a95c4d --- /dev/null +++ b/pkg/commands/compute/setup/products.go @@ -0,0 +1,403 @@ +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/v13/fastly" + "github.com/fastly/go-fastly/v13/fastly/products/apidiscovery" + "github.com/fastly/go-fastly/v13/fastly/products/botmanagement" + "github.com/fastly/go-fastly/v13/fastly/products/brotlicompression" + "github.com/fastly/go-fastly/v13/fastly/products/ddosprotection" + "github.com/fastly/go-fastly/v13/fastly/products/domaininspector" + "github.com/fastly/go-fastly/v13/fastly/products/fanout" + "github.com/fastly/go-fastly/v13/fastly/products/imageoptimizer" + "github.com/fastly/go-fastly/v13/fastly/products/logexplorerinsights" + "github.com/fastly/go-fastly/v13/fastly/products/ngwaf" + "github.com/fastly/go-fastly/v13/fastly/products/origininspector" + "github.com/fastly/go-fastly/v13/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 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(_ io.Writer, p *ProductsMap, _ manifest.SetupProductSettings) error { + p.DdosProtection = NewProductEnabled() + return nil + }, + getConfiguredProduct: func(products *ProductsMap) ProductSettings { + return products.DdosProtection + }, + enable: func(fc *fastly.Client, _ ProductSettings, serviceID string) error { + _, err := ddosprotection.Enable(context.TODO(), fc, serviceID) + 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/setup.go b/pkg/manifest/setup.go index e649a5f25..3e2ed10ab 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 } @@ -87,3 +90,52 @@ 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"` +} + +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) 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 diff --git a/pkg/mock/client.go b/pkg/mock/client.go index 4a5cfb138..8f2d67e7f 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..d5700424d --- /dev/null +++ b/pkg/mock/fastly_client.go @@ -0,0 +1,16 @@ +package mock + +import ( + "github.com/fastly/go-fastly/v13/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 }