From fa887d8dcdceefbc0b07f320644039ed4c98b466 Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Tue, 3 Mar 2026 22:00:35 +0900 Subject: [PATCH 01/14] Implement [setup.products] --- pkg/commands/compute/deploy.go | 31 ++ pkg/commands/compute/setup/products.go | 391 ++++++++++++++++++ pkg/manifest/setup.go | 64 ++- .../testdata/fastly-viceroy-update.toml | 5 + 4 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 pkg/commands/compute/setup/products.go diff --git a/pkg/commands/compute/deploy.go b/pkg/commands/compute/deploy.go index 9a11176fe..ce68bb1ec 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..72808805d --- /dev/null +++ b/pkg/commands/compute/setup/products.go @@ -0,0 +1,391 @@ +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 { + 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/manifest/setup.go b/pkg/manifest/setup.go index e649a5f25..e2db86fe9 100644 --- a/pkg/manifest/setup.go +++ b/pkg/manifest/setup.go @@ -1,5 +1,7 @@ package manifest +import "reflect" + // 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 +11,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 +36,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 +92,60 @@ 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 { + if p == nil { + return false + } + + rv := reflect.ValueOf(p).Elem() // SetupProducts + settingsT := reflect.TypeOf((*SetupProductSettings)(nil)).Elem() + + for i := 0; i < rv.NumField(); i++ { + fv := rv.Field(i) + if fv.Kind() != reflect.Ptr || fv.IsNil() { + continue + } + + if fv.Type().Implements(settingsT) { + return true + } + } + + return false +} + +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 From b01f2f8ab149c446740d3258228bd402c1ca1432 Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Tue, 3 Mar 2026 22:02:04 +0900 Subject: [PATCH 02/14] New test utils to mock fastly.Client directly --- pkg/mock/client.go | 38 +++++++++++++++++ pkg/mock/fastly_client.go | 16 +++++++ pkg/mock/round_tripper.go | 44 ++++++++++++++++++++ pkg/testutil/http.go | 87 +++++++++++++++++++++++++++++++++++++++ pkg/testutil/string.go | 3 ++ 5 files changed, 188 insertions(+) create mode 100644 pkg/mock/fastly_client.go create mode 100644 pkg/mock/round_tripper.go create mode 100644 pkg/testutil/http.go diff --git a/pkg/mock/client.go b/pkg/mock/client.go index 4a5cfb138..1ec4457b0 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 a 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 stubbed response and +// no errors, 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 } From bb85d9e282615a100b6d8f429194c708d8265da7 Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Tue, 3 Mar 2026 22:02:19 +0900 Subject: [PATCH 03/14] Tests for [setup.products] --- .../compute/setup/products_create_test.go | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 pkg/commands/compute/setup/products_create_test.go 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..edfe22151 --- /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) + } + }) + } +} From 48b9b2cf9e09186e2ce7f58cc05e4dcc64b12ea0 Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Tue, 3 Mar 2026 22:02:48 +0900 Subject: [PATCH 04/14] Changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa884338a..31d606f48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Enhancements: +- 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)) From bb79f643ef426ddcc1b672c1cf84231cd30c2b0a Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Tue, 10 Mar 2026 19:19:58 +0900 Subject: [PATCH 05/14] Spell Ngwaf -> NGWAF --- pkg/commands/compute/setup/products.go | 20 +++++++++---------- .../compute/setup/products_create_test.go | 2 +- pkg/manifest/setup.go | 6 +++--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pkg/commands/compute/setup/products.go b/pkg/commands/compute/setup/products.go index 72808805d..eed150332 100644 --- a/pkg/commands/compute/setup/products.go +++ b/pkg/commands/compute/setup/products.go @@ -57,7 +57,7 @@ type ProductsMap struct { Fanout ProductSettings ImageOptimizer ProductSettings LogExplorerInsights ProductSettings - Ngwaf ProductSettings + NGWAF ProductSettings OriginInspector ProductSettings WebSockets ProductSettings } @@ -80,19 +80,19 @@ func (p *Product) Enabled() bool { return p != nil && p.Enable } -type ProductNgwaf struct { +type ProductNGWAF struct { Product WorkspaceID string } -func NewProductNgWaf(workspaceID string) *ProductNgwaf { - return &ProductNgwaf{ +func NewProductNGWAF(workspaceID string) *ProductNGWAF { + return &ProductNGWAF{ Product: *NewProductEnabled(), WorkspaceID: workspaceID, } } -var _ ProductSettings = (*ProductNgwaf)(nil) +var _ ProductSettings = (*ProductNGWAF)(nil) type productsSpec struct { id string @@ -255,10 +255,10 @@ func init() { id: ngwaf.ProductID, name: ngwaf.ProductName, getSetupProduct: func(setupProducts *manifest.SetupProducts) manifest.SetupProductSettings { - return setupProducts.Ngwaf + return setupProducts.NGWAF }, configure: func(w io.Writer, p *ProductsMap, sp manifest.SetupProductSettings) error { - ngwafSetupProduct, ok := sp.(*manifest.SetupProductNgwaf) + ngwafSetupProduct, ok := sp.(*manifest.SetupProductNGWAF) if !ok { return fmt.Errorf("unexpected: Incorrect type for setupProduct") } @@ -266,14 +266,14 @@ func init() { return fmt.Errorf("workspace_id is required") } text.Output(w, " workspace_id: %s", ngwafSetupProduct.WorkspaceID) - p.Ngwaf = NewProductNgWaf(ngwafSetupProduct.WorkspaceID) + p.NGWAF = NewProductNGWAF(ngwafSetupProduct.WorkspaceID) return nil }, getConfiguredProduct: func(products *ProductsMap) ProductSettings { - return products.Ngwaf + return products.NGWAF }, enable: func(fc *fastly.Client, product ProductSettings, serviceID string) error { - ngwafProduct, ok := product.(*ProductNgwaf) + ngwafProduct, ok := product.(*ProductNGWAF) if !ok { return fmt.Errorf("unexpected: Incorrect type for product") } diff --git a/pkg/commands/compute/setup/products_create_test.go b/pkg/commands/compute/setup/products_create_test.go index edfe22151..68a354b43 100644 --- a/pkg/commands/compute/setup/products_create_test.go +++ b/pkg/commands/compute/setup/products_create_test.go @@ -108,7 +108,7 @@ func TestProductsCreate(t *testing.T) { { name: "successfully enables ngwaf with workspace id", setup: &manifest.SetupProducts{ - Ngwaf: &manifest.SetupProductNgwaf{ + NGWAF: &manifest.SetupProductNGWAF{ SetupProduct: manifest.SetupProduct{ Enable: true, }, diff --git a/pkg/manifest/setup.go b/pkg/manifest/setup.go index e2db86fe9..e0fd6c564 100644 --- a/pkg/manifest/setup.go +++ b/pkg/manifest/setup.go @@ -102,7 +102,7 @@ type SetupProducts struct { Fanout *SetupProduct `toml:"fanout,omitempty"` ImageOptimizer *SetupProduct `toml:"image_optimizer,omitempty"` LogExplorerInsights *SetupProduct `toml:"log_explorer_insights,omitempty"` - Ngwaf *SetupProductNgwaf `toml:"ngwaf,omitempty"` + NGWAF *SetupProductNGWAF `toml:"ngwaf,omitempty"` OriginInspector *SetupProduct `toml:"origin_inspector,omitempty"` WebSockets *SetupProduct `toml:"websockets,omitempty"` } @@ -143,9 +143,9 @@ func (p *SetupProduct) Enabled() bool { return p != nil && p.Enable } -type SetupProductNgwaf struct { +type SetupProductNGWAF struct { SetupProduct WorkspaceID string `toml:"workspace_id,omitempty"` } -var _ SetupProductSettings = (*SetupProductNgwaf)(nil) +var _ SetupProductSettings = (*SetupProductNGWAF)(nil) From fa550a08185d326c7fda5be3c81f176b50609d32 Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Tue, 10 Mar 2026 19:22:06 +0900 Subject: [PATCH 06/14] Correct comment for NewHTTPClientWithErrors --- pkg/mock/client.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/mock/client.go b/pkg/mock/client.go index 1ec4457b0..8f2d67e7f 100644 --- a/pkg/mock/client.go +++ b/pkg/mock/client.go @@ -71,7 +71,7 @@ func NewHTTPClient(res []*http.Response, err []error, saveRequests bool) *HTTPCl } } -// NewHTTPClientDefault returns a mock HTTP Client that returns a stubbed responses and +// 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) @@ -83,8 +83,8 @@ func NewHTTPClientWithResponses(res []*http.Response) *HTTPClient { return NewHTTPClientDefault(res, make([]error, len(res))) } -// NewHTTPClientWithErrors returns a mock HTTP Client that returns stubbed response and -// no errors, and saves requests. +// 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) } From 21a97e18a3566440c95bf2d00363e9776cb7c34d Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Tue, 10 Mar 2026 19:25:19 +0900 Subject: [PATCH 07/14] simplify AnyDefined --- pkg/manifest/setup.go | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/pkg/manifest/setup.go b/pkg/manifest/setup.go index e0fd6c564..3e2ed10ab 100644 --- a/pkg/manifest/setup.go +++ b/pkg/manifest/setup.go @@ -1,7 +1,5 @@ package manifest -import "reflect" - // 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 { @@ -108,25 +106,17 @@ type SetupProducts struct { } func (p *SetupProducts) AnyDefined() bool { - if p == nil { - return false - } - - rv := reflect.ValueOf(p).Elem() // SetupProducts - settingsT := reflect.TypeOf((*SetupProductSettings)(nil)).Elem() - - for i := 0; i < rv.NumField(); i++ { - fv := rv.Field(i) - if fv.Kind() != reflect.Ptr || fv.IsNil() { - continue - } - - if fv.Type().Implements(settingsT) { - return true - } - } - - return false + 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 { From 6d6d1ccf32286d67e0e6294884ffcc0956100bf1 Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Tue, 10 Mar 2026 19:35:30 +0900 Subject: [PATCH 08/14] Implement anyEnabled --- pkg/commands/compute/setup/products.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/commands/compute/setup/products.go b/pkg/commands/compute/setup/products.go index eed150332..661a95c4d 100644 --- a/pkg/commands/compute/setup/products.go +++ b/pkg/commands/compute/setup/products.go @@ -346,6 +346,18 @@ func (p *Products) Configure() error { // 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"), From ec24601e10eb48ac3e23e47a325a4a4a8ba0fbee Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Mon, 8 Jun 2026 16:44:18 +0900 Subject: [PATCH 09/14] Apply suggestion from PR --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8068c789..0d2ee0fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,7 +163,7 @@ - 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 ([#1617](https://github.com/fastly/cli/pull/1617)) +- feat(compute/deploy): Apply \[setup.products] for enabling products during initial deploy or publish ([#1617](https://github.com/fastly/cli/pull/1617)) ### Dependencies: From fa60de0743d92aa5568d21bf70b53a4e3ea83aee Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Mon, 8 Jun 2026 16:46:37 +0900 Subject: [PATCH 10/14] Update to go-fastly@v15 --- pkg/commands/compute/setup/products.go | 24 ++++++++++++------------ pkg/mock/fastly_client.go | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pkg/commands/compute/setup/products.go b/pkg/commands/compute/setup/products.go index 661a95c4d..38ddbedde 100644 --- a/pkg/commands/compute/setup/products.go +++ b/pkg/commands/compute/setup/products.go @@ -12,18 +12,18 @@ import ( 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" + "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 diff --git a/pkg/mock/fastly_client.go b/pkg/mock/fastly_client.go index d5700424d..da910c59a 100644 --- a/pkg/mock/fastly_client.go +++ b/pkg/mock/fastly_client.go @@ -1,7 +1,7 @@ package mock import ( - "github.com/fastly/go-fastly/v13/fastly" + "github.com/fastly/go-fastly/v15/fastly" ) func NewFastlyClient(httpClient *HTTPClient) (*fastly.Client, error) { From fb4af1624ec6e13851a5df86843a841488934f52 Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Mon, 8 Jun 2026 16:47:32 +0900 Subject: [PATCH 11/14] Support "mode" configuration on "ddos_protection" product --- pkg/commands/compute/setup/products.go | 32 ++++++++++++++++++++++---- pkg/manifest/setup.go | 7 ++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/pkg/commands/compute/setup/products.go b/pkg/commands/compute/setup/products.go index 38ddbedde..0ebd371dd 100644 --- a/pkg/commands/compute/setup/products.go +++ b/pkg/commands/compute/setup/products.go @@ -92,6 +92,18 @@ func NewProductNGWAF(workspaceID string) *ProductNGWAF { } } +type ProductDdosProtection struct { + Product + Mode string +} + +func NewProductDdosProtection(mode string) *ProductDdosProtection { + return &ProductDdosProtection{ + Product: *NewProductEnabled(), + Mode: mode, + } +} + var _ ProductSettings = (*ProductNGWAF)(nil) type productsSpec struct { @@ -167,15 +179,27 @@ func init() { getSetupProduct: func(setupProducts *manifest.SetupProducts) manifest.SetupProductSettings { return setupProducts.DdosProtection }, - configure: func(_ io.Writer, p *ProductsMap, _ manifest.SetupProductSettings) error { - p.DdosProtection = NewProductEnabled() + 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, _ ProductSettings, serviceID string) error { - _, err := ddosprotection.Enable(context.TODO(), fc, serviceID) + 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 }, }, diff --git a/pkg/manifest/setup.go b/pkg/manifest/setup.go index b273b3315..9e4a751e8 100644 --- a/pkg/manifest/setup.go +++ b/pkg/manifest/setup.go @@ -140,3 +140,10 @@ type SetupProductNGWAF struct { } var _ SetupProductSettings = (*SetupProductNGWAF)(nil) + +type SetupProductDdosProtection struct { + SetupProduct + Mode string `toml:"mode,omitempty"` +} + +var _ SetupProductSettings = (*SetupProductDdosProtection)(nil) From ec1e067fa48cacc1339dc075e7430c827e18c332 Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Wed, 10 Jun 2026 11:34:39 +0900 Subject: [PATCH 12/14] Fix from PR Co-authored-by: Kevin P. Fleming --- pkg/manifest/setup.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/manifest/setup.go b/pkg/manifest/setup.go index 9e4a751e8..9a8e64f94 100644 --- a/pkg/manifest/setup.go +++ b/pkg/manifest/setup.go @@ -96,7 +96,7 @@ 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"` + DdosProtection *SetupProductDdosProtection `toml:"ddos_protection,omitempty"` DomainInspector *SetupProduct `toml:"domain_inspector,omitempty"` Fanout *SetupProduct `toml:"fanout,omitempty"` ImageOptimizer *SetupProduct `toml:"image_optimizer,omitempty"` From bc7eafb77626f14cc07d8740c26b78f2f68c75f7 Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Wed, 10 Jun 2026 11:36:22 +0900 Subject: [PATCH 13/14] Casing of "DDoS" --- pkg/commands/compute/setup/products.go | 22 +++++++++++--------- pkg/manifest/setup.go | 28 +++++++++++++------------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/pkg/commands/compute/setup/products.go b/pkg/commands/compute/setup/products.go index 0ebd371dd..16de72883 100644 --- a/pkg/commands/compute/setup/products.go +++ b/pkg/commands/compute/setup/products.go @@ -52,7 +52,7 @@ type ProductsMap struct { APIDiscovery ProductSettings BotManagement ProductSettings BrotliCompression ProductSettings - DdosProtection ProductSettings + DDoSProtection ProductSettings DomainInspector ProductSettings Fanout ProductSettings ImageOptimizer ProductSettings @@ -92,19 +92,21 @@ func NewProductNGWAF(workspaceID string) *ProductNGWAF { } } -type ProductDdosProtection struct { +var _ ProductSettings = (*ProductNGWAF)(nil) + +type ProductDDoSProtection struct { Product Mode string } -func NewProductDdosProtection(mode string) *ProductDdosProtection { - return &ProductDdosProtection{ +func NewProductDDoSProtection(mode string) *ProductDDoSProtection { + return &ProductDDoSProtection{ Product: *NewProductEnabled(), Mode: mode, } } -var _ ProductSettings = (*ProductNGWAF)(nil) +var _ ProductSettings = (*ProductDDoSProtection)(nil) type productsSpec struct { id string @@ -177,10 +179,10 @@ func init() { id: ddosprotection.ProductID, name: ddosprotection.ProductName, getSetupProduct: func(setupProducts *manifest.SetupProducts) manifest.SetupProductSettings { - return setupProducts.DdosProtection + return setupProducts.DDoSProtection }, configure: func(w io.Writer, p *ProductsMap, sp manifest.SetupProductSettings) error { - ddosProtectionSetupProduct, ok := sp.(*manifest.SetupProductDdosProtection) + ddosProtectionSetupProduct, ok := sp.(*manifest.SetupProductDDoSProtection) if !ok { return fmt.Errorf("unexpected: Incorrect type for setupProduct") } @@ -188,14 +190,14 @@ func init() { return fmt.Errorf("mode is required") } text.Output(w, " mode: %s", ddosProtectionSetupProduct.Mode) - p.DdosProtection = NewProductDdosProtection(ddosProtectionSetupProduct.Mode) + p.DDoSProtection = NewProductDDoSProtection(ddosProtectionSetupProduct.Mode) return nil }, getConfiguredProduct: func(products *ProductsMap) ProductSettings { - return products.DdosProtection + return products.DDoSProtection }, enable: func(fc *fastly.Client, product ProductSettings, serviceID string) error { - ddosProtectionProduct, ok := product.(*ProductDdosProtection) + ddosProtectionProduct, ok := product.(*ProductDDoSProtection) if !ok { return fmt.Errorf("unexpected: Incorrect type for product") } diff --git a/pkg/manifest/setup.go b/pkg/manifest/setup.go index 9a8e64f94..f6fd7ae09 100644 --- a/pkg/manifest/setup.go +++ b/pkg/manifest/setup.go @@ -93,24 +93,24 @@ type SetupSecretStoreEntry struct { } 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"` + 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.DDoSProtection != nil || p.DomainInspector != nil || p.Fanout != nil || p.ImageOptimizer != nil || @@ -141,9 +141,9 @@ type SetupProductNGWAF struct { var _ SetupProductSettings = (*SetupProductNGWAF)(nil) -type SetupProductDdosProtection struct { +type SetupProductDDoSProtection struct { SetupProduct Mode string `toml:"mode,omitempty"` } -var _ SetupProductSettings = (*SetupProductDdosProtection)(nil) +var _ SetupProductSettings = (*SetupProductDDoSProtection)(nil) From 0d3aa388f2b2c06656d8d041893e58bf95f1eed6 Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Wed, 10 Jun 2026 11:56:20 +0900 Subject: [PATCH 14/14] Update tests --- pkg/manifest/manifest_test.go | 82 ++++++++++++++++++- .../testdata/fastly-viceroy-update.toml | 6 ++ 2 files changed, 84 insertions(+), 4 deletions(-) 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/testdata/fastly-viceroy-update.toml b/pkg/manifest/testdata/fastly-viceroy-update.toml index b95e3785c..3a561c8c7 100644 --- a/pkg/manifest/testdata/fastly-viceroy-update.toml +++ b/pkg/manifest/testdata/fastly-viceroy-update.toml @@ -78,5 +78,11 @@ 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"