From 5cf037838cf63d7c85f3f2770221abcfa2d63e2e Mon Sep 17 00:00:00 2001 From: Richard Carillo Date: Fri, 13 Mar 2026 14:55:08 -0400 Subject: [PATCH 1/6] feat(apisecurity): Adds support for discovered operations and operations commands. Adds support for list & udpate discovered operations commands and CRUD for operation commands. --- .../discoveredoperations_test.go | 543 ++++++++++++++++++ .../apisecurity/discoveredoperations/doc.go | 2 + .../apisecurity/discoveredoperations/list.go | 195 +++++++ .../apisecurity/discoveredoperations/root.go | 31 + .../discoveredoperations/update.go | 318 ++++++++++ pkg/commands/apisecurity/doc.go | 2 + .../apisecurity/operations/addtags.go | 210 +++++++ pkg/commands/apisecurity/operations/create.go | 310 ++++++++++ pkg/commands/apisecurity/operations/delete.go | 101 ++++ .../apisecurity/operations/describe.go | 117 ++++ pkg/commands/apisecurity/operations/doc.go | 2 + pkg/commands/apisecurity/operations/list.go | 198 +++++++ .../apisecurity/operations/operations_test.go | 287 +++++++++ pkg/commands/apisecurity/operations/root.go | 31 + pkg/commands/apisecurity/operations/update.go | 134 +++++ pkg/commands/apisecurity/root.go | 31 + pkg/commands/commands.go | 25 + 17 files changed, 2537 insertions(+) create mode 100644 pkg/commands/apisecurity/discoveredoperations/discoveredoperations_test.go create mode 100644 pkg/commands/apisecurity/discoveredoperations/doc.go create mode 100644 pkg/commands/apisecurity/discoveredoperations/list.go create mode 100644 pkg/commands/apisecurity/discoveredoperations/root.go create mode 100644 pkg/commands/apisecurity/discoveredoperations/update.go create mode 100644 pkg/commands/apisecurity/doc.go create mode 100644 pkg/commands/apisecurity/operations/addtags.go create mode 100644 pkg/commands/apisecurity/operations/create.go create mode 100644 pkg/commands/apisecurity/operations/delete.go create mode 100644 pkg/commands/apisecurity/operations/describe.go create mode 100644 pkg/commands/apisecurity/operations/doc.go create mode 100644 pkg/commands/apisecurity/operations/list.go create mode 100644 pkg/commands/apisecurity/operations/operations_test.go create mode 100644 pkg/commands/apisecurity/operations/root.go create mode 100644 pkg/commands/apisecurity/operations/update.go create mode 100644 pkg/commands/apisecurity/root.go diff --git a/pkg/commands/apisecurity/discoveredoperations/discoveredoperations_test.go b/pkg/commands/apisecurity/discoveredoperations/discoveredoperations_test.go new file mode 100644 index 000000000..9c8e9bf83 --- /dev/null +++ b/pkg/commands/apisecurity/discoveredoperations/discoveredoperations_test.go @@ -0,0 +1,543 @@ +package discoveredoperations_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + apisecurity "github.com/fastly/cli/pkg/commands/apisecurity" + root "github.com/fastly/cli/pkg/commands/apisecurity/discoveredoperations" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v13/fastly/apisecurity/operations" +) + +const ( + serviceID = "test-service-id" + operationID = "test-operation-id" +) + +var ( + listResponse = operations.DiscoveredOperations{ + Data: []operations.DiscoveredOperation{ + { + ID: "test-operation-id", + Method: "GET", + Domain: "example.com", + Path: "/api/users", + Status: "DISCOVERED", + RPS: 10.5, + LastSeenAt: "2026-03-10T12:00:00Z", + UpdatedAt: "2026-03-10T12:00:00Z", + }, + { + ID: "test-operation-id-2", + Method: "POST", + Domain: "example.com", + Path: "/api/users", + Status: "SAVED", + RPS: 5.2, + LastSeenAt: "2026-03-10T12:00:00Z", + UpdatedAt: "2026-03-10T12:00:00Z", + }, + }, + Meta: operations.Meta{ + Limit: 2, + Total: 2, + }, + } + + updateResponse = operations.DiscoveredOperation{ + ID: "test-operation-id", + Method: "GET", + Domain: "example.com", + Path: "/api/users", + Status: "IGNORED", + RPS: 10.5, + LastSeenAt: "2026-03-10T12:00:00Z", + UpdatedAt: "2026-03-10T13:00:00Z", + } + + updateResponseJSON = testutil.GenJSON(updateResponse) + listResponseJSON = testutil.GenJSON(listResponse) + + bulkResponse = operations.BulkOperationResultsResponse{ + Data: []operations.BulkOperationResult{ + { + ID: "op-id-1", + StatusCode: 200, + }, + { + ID: "op-id-2", + StatusCode: 200, + }, + }, + } + bulkResponseJSON = testutil.GenJSON(bulkResponse) + + listDiscoveredOperationsOutput = strings.TrimSpace(` +METHOD DOMAIN PATH STATUS RPS LAST SEEN +GET example.com /api/users DISCOVERED 10.50 2026-03-10T12:00:00Z +POST example.com /api/users SAVED 5.20 2026-03-10T12:00:00Z +`) + "\n" + + listDiscoveredOperationsVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): test-service-id + + +Discovered Operation 1/2 + ID: test-operation-id + Method: GET + Domain: example.com + Path: /api/users + Status: DISCOVERED + RPS: 10.50 + Last Seen: 2026-03-10T12:00:00Z + Updated At: 2026-03-10T12:00:00Z + +Discovered Operation 2/2 + ID: test-operation-id-2 + Method: POST + Domain: example.com + Path: /api/users + Status: SAVED + RPS: 5.20 + Last Seen: 2026-03-10T12:00:00Z + Updated At: 2026-03-10T12:00:00Z +`) + "\n\n" +) + +func TestListCommand(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --service-id flag", + Args: "--status discovered", + WantError: "error parsing arguments: required flag --service-id not provided", + }, + { + Name: "validate list without status filter", + Args: fmt.Sprintf("--service-id %s", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listDiscoveredOperationsOutput, + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--service-id %s", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listDiscoveredOperationsOutput, + }, + { + Name: "validate --json flag", + Args: fmt.Sprintf("--service-id %s --status saved --json", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(listResponseJSON)), + }, + }, + }, + WantOutput: string(listResponseJSON), + }, + { + Name: "validate invalid status", + Args: fmt.Sprintf("--service-id %s --status invalid", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantError: "invalid status: invalid. Valid options: 'discovered', 'saved', 'ignored'", + }, + { + Name: "validate API error", + Args: fmt.Sprintf("--service-id %s --status discovered", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + Body: io.NopCloser(strings.NewReader(`{"detail":"Internal Server Error"}`)), + }, + }, + }, + WantError: "500", + }, + } + + testutil.RunCLIScenarios(t, []string{apisecurity.CommandName, root.CommandName, "list"}, scenarios) +} + +func TestListCommandWithPagination(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate --page flag", + Args: fmt.Sprintf("--service-id %s --page 1", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listDiscoveredOperationsOutput, + }, + { + Name: "validate --per-page flag", + Args: fmt.Sprintf("--service-id %s --per-page 50", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listDiscoveredOperationsOutput, + }, + { + Name: "validate --page and --per-page together", + Args: fmt.Sprintf("--service-id %s --status discovered --page 2 --per-page 25", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listDiscoveredOperationsOutput, + }, + } + + testutil.RunCLIScenarios(t, []string{apisecurity.CommandName, root.CommandName, "list"}, scenarios) +} + +func TestListCommandWithFilters(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate --domain filter", + Args: fmt.Sprintf("--service-id %s --status discovered --domain example.com", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listDiscoveredOperationsOutput, + }, + { + Name: "validate --method filter", + Args: fmt.Sprintf("--service-id %s --status discovered --method GET", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listDiscoveredOperationsOutput, + }, + { + Name: "validate --path filter", + Args: fmt.Sprintf("--service-id %s --status discovered --path /api/users", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listDiscoveredOperationsOutput, + }, + { + Name: "validate --verbose output", + Args: fmt.Sprintf("--service-id %s --status discovered --verbose", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listDiscoveredOperationsVerboseOutput, + }, + { + Name: "validate empty results", + Args: fmt.Sprintf("--service-id %s --status discovered", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(operations.DiscoveredOperations{ + Data: []operations.DiscoveredOperation{}, + Meta: operations.Meta{ + Limit: 0, + Total: 0, + }, + }))), + }, + }, + }, + }, + } + + testutil.RunCLIScenarios(t, []string{apisecurity.CommandName, root.CommandName, "list"}, scenarios) +} + +func TestUpdateCommand(t *testing.T) { + // Create temp file for bulk update test + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test-ops.json") + content := `{"operation_ids": ["op-id-1", "op-id-2"], "status": "ignored"}` + err := os.WriteFile(testFile, []byte(content), 0o600) + if err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + discoveredResponse := operations.DiscoveredOperation{ + ID: "test-operation-id", + Method: "GET", + Domain: "example.com", + Path: "/api/users", + Status: "DISCOVERED", + RPS: 10.5, + LastSeenAt: "2026-03-10T12:00:00Z", + UpdatedAt: "2026-03-10T13:00:00Z", + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --service-id flag", + Args: fmt.Sprintf("--operation-id %s --status ignored", operationID), + WantError: "error parsing arguments: required flag --service-id not provided", + }, + { + Name: "validate missing --operation-id and --file flags", + Args: fmt.Sprintf("--service-id %s --status ignored", serviceID), + WantError: "error parsing arguments: must provide either --operation-id or --file", + }, + { + Name: "validate missing --status flag", + Args: fmt.Sprintf("--service-id %s --operation-id %s", serviceID, operationID), + WantError: "error parsing arguments: --status is required when using --operation-id", + }, + { + Name: "validate invalid status", + Args: fmt.Sprintf("--service-id %s --operation-id %s --status invalid", serviceID, operationID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updateResponse))), + }, + }, + }, + WantError: "invalid status: invalid. Valid options: 'discovered', 'ignored'", + }, + { + Name: "validate API success with status ignored", + Args: fmt.Sprintf("--service-id %s --operation-id %s --status ignored", serviceID, operationID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updateResponse))), + }, + }, + }, + WantOutputs: []string{ + "Updated discovered operation:", + "ID: test-operation-id", + "Method: GET", + "Domain: example.com", + "Path: /api/users", + "Status: IGNORED", + }, + }, + { + Name: "validate API success with status discovered", + Args: fmt.Sprintf("--service-id %s --operation-id %s --status discovered", serviceID, operationID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(discoveredResponse))), + }, + }, + }, + WantOutputs: []string{ + "Updated discovered operation:", + "Status: DISCOVERED", + }, + }, + { + Name: "validate --json flag", + Args: fmt.Sprintf("--service-id %s --operation-id %s --status ignored --json", serviceID, operationID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(updateResponseJSON)), + }, + }, + }, + WantOutput: string(updateResponseJSON), + }, + { + Name: "validate API error", + Args: fmt.Sprintf("--service-id %s --operation-id %s --status ignored", serviceID, operationID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(strings.NewReader(`{"detail":"Not Found"}`)), + }, + }, + }, + WantError: "404", + }, + { + Name: "validate bulk mode with --json flag", + Args: fmt.Sprintf("--service-id %s --file %s --json", serviceID, testFile), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusMultiStatus, + Status: http.StatusText(http.StatusMultiStatus), + Body: io.NopCloser(bytes.NewReader(bulkResponseJSON)), + }, + }, + }, + WantOutput: string(bulkResponseJSON), + }, + } + + testutil.RunCLIScenarios(t, []string{apisecurity.CommandName, root.CommandName, "update"}, scenarios) +} + +func TestUpdateCommandEdgeCases(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate cannot use both --operation-id and --file", + Args: fmt.Sprintf("--service-id %s --operation-id %s --file /tmp/test.json --status ignored", serviceID, operationID), + WantError: "error parsing arguments: cannot use both --operation-id and --file", + }, + { + Name: "validate cannot use --file with --status flag", + Args: fmt.Sprintf("--service-id %s --file /tmp/test.json --status ignored", serviceID), + WantError: "error parsing arguments: cannot use both --file and --status", + }, + { + Name: "validate comma-separated operation IDs", + Args: fmt.Sprintf("--service-id %s --operation-id op-id-1,op-id-2 --status ignored", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusMultiStatus, + Status: http.StatusText(http.StatusMultiStatus), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(bulkResponse))), + }, + }, + }, + WantOutputs: []string{ + "Updated 2 discovered operation(s)", + }, + }, + { + Name: "validate --verbose with bulk update", + Args: fmt.Sprintf("--service-id %s --operation-id op-id-1,op-id-2 --status ignored --verbose", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusMultiStatus, + Status: http.StatusText(http.StatusMultiStatus), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(bulkResponse))), + }, + }, + }, + WantOutputs: []string{ + "Updated 2 discovered operation(s)", + "Updating 2 operation(s) with status: IGNORED", + "OPERATION ID", + "STATUS CODE", + "RESULT", + "op-id-1", + "200", + "Success", + }, + }, + { + Name: "validate bulk update with mixed results", + Args: fmt.Sprintf("--service-id %s --operation-id op-id-1,op-id-2,op-id-3 --status ignored", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusMultiStatus, + Status: http.StatusText(http.StatusMultiStatus), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(operations.BulkOperationResultsResponse{ + Data: []operations.BulkOperationResult{ + {ID: "op-id-1", StatusCode: 200}, + {ID: "op-id-2", StatusCode: 404, Reason: "Not Found"}, + {ID: "op-id-3", StatusCode: 200}, + }, + }))), + }, + }, + }, + WantOutputs: []string{ + "Updated 2 discovered operation(s)", + "1 operation(s) failed to update", + }, + }, + } + + testutil.RunCLIScenarios(t, []string{apisecurity.CommandName, root.CommandName, "update"}, scenarios) +} diff --git a/pkg/commands/apisecurity/discoveredoperations/doc.go b/pkg/commands/apisecurity/discoveredoperations/doc.go new file mode 100644 index 000000000..3aff40863 --- /dev/null +++ b/pkg/commands/apisecurity/discoveredoperations/doc.go @@ -0,0 +1,2 @@ +// Package discoveredoperations contains commands to list and update discovered operations. +package discoveredoperations diff --git a/pkg/commands/apisecurity/discoveredoperations/list.go b/pkg/commands/apisecurity/discoveredoperations/list.go new file mode 100644 index 000000000..993311c57 --- /dev/null +++ b/pkg/commands/apisecurity/discoveredoperations/list.go @@ -0,0 +1,195 @@ +package discoveredoperations + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/fastly/go-fastly/v13/fastly" + "github.com/fastly/go-fastly/v13/fastly/apisecurity/operations" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list discovered API operations. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + input operations.ListDiscoveredInput + serviceName argparser.OptionalServiceNameID + + // Optional. + domain argparser.OptionalString + method argparser.OptionalString + path argparser.OptionalString + status argparser.OptionalString + page argparser.OptionalInt + perPage argparser.OptionalInt +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List discovered operations") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + Required: true, + }) + + // Optional. + c.CmdClause.Flag("status", "Filters operations by status. Valid values are: discovered, saved, ignored").Action(c.status.Set).StringVar(&c.status.Value) + c.CmdClause.Flag("domain", "The domain for the operation").Action(c.domain.Set).StringVar(&c.domain.Value) + c.CmdClause.Flag("method", "Filters operations by HTTP method (e.g., GET, POST, PUT)").Action(c.method.Set).StringVar(&c.method.Value) + c.CmdClause.Flag("path", "Filters operations by path (exact match)").Action(c.path.Set).StringVar(&c.path.Value) + c.CmdClause.Flag("page", "Page number for pagination (0-indexed)").Action(c.page.Set).IntVar(&c.page.Value) + c.CmdClause.Flag("per-page", "Number of items per page (default: 100)").Action(c.perPage.Set).IntVar(&c.perPage.Value) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + c.input.ServiceID = &serviceID + + // The API only accepts uppercase values for 'status', + // so we are handling accordingly here and allowing + // end users to still use the normal lowercase pattern + // for input in the CLI. + if c.status.WasSet { + switch c.status.Value { + case "discovered": + status := "DISCOVERED" + c.input.Status = &status + case "saved": + status := "SAVED" + c.input.Status = &status + case "ignored": + status := "IGNORED" + c.input.Status = &status + default: + err := fmt.Errorf("invalid status: %s. Valid options: 'discovered', 'saved', 'ignored'", c.status.Value) + c.Globals.ErrLog.Add(err) + return err + } + } + if c.domain.WasSet { + c.input.Domain = []string{c.domain.Value} + } + if c.method.WasSet { + c.input.Method = []string{c.method.Value} + } + if c.path.WasSet { + c.input.Path = &c.path.Value + } + if c.page.WasSet { + c.input.Page = &c.page.Value + } + if c.perPage.WasSet { + c.input.Limit = &c.perPage.Value + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + o, err := operations.ListDiscovered(context.TODO(), fc, &c.input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Domain": c.domain.Value, + "Method": c.method.Value, + "Status": c.status.Value, + "Path": c.path.Value, + "Page": c.page.Value, + "Per Page": c.perPage.Value, + }) + return err + } + + if o == nil { + o = &operations.DiscoveredOperations{ + Data: []operations.DiscoveredOperation{}, + } + } + + if ok, err := c.WriteJSON(out, o.Data); ok { + return err + } + + if !c.Globals.Verbose() { + return c.printSummary(out, o.Data) + } + + return c.printVerbose(out, o.Data) +} + +// printSummary displays the discovered operations in a table format. +func (c *ListCommand) printSummary(out io.Writer, o []operations.DiscoveredOperation) error { + tw := text.NewTable(out) + tw.AddHeader("METHOD", "DOMAIN", "PATH", "STATUS", "RPS", "LAST SEEN") + for _, op := range o { + tw.AddLine( + strings.ToUpper(op.Method), + op.Domain, + op.Path, + op.Status, + fmt.Sprintf("%.2f", op.RPS), + op.LastSeenAt, + ) + } + tw.Print() + + return nil +} + +// printVerbose displays detailed information for each discovered operation. +func (c *ListCommand) printVerbose(out io.Writer, o []operations.DiscoveredOperation) error { + for i, op := range o { + fmt.Fprintf(out, "\nDiscovered Operation %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\tID: %s\n", op.ID) + fmt.Fprintf(out, "\tMethod: %s\n", strings.ToUpper(op.Method)) + fmt.Fprintf(out, "\tDomain: %s\n", op.Domain) + fmt.Fprintf(out, "\tPath: %s\n", op.Path) + fmt.Fprintf(out, "\tStatus: %s\n", op.Status) + fmt.Fprintf(out, "\tRPS: %.2f\n", op.RPS) + if op.LastSeenAt != "" { + fmt.Fprintf(out, "\tLast Seen: %s\n", op.LastSeenAt) + } + if op.UpdatedAt != "" { + fmt.Fprintf(out, "\tUpdated At: %s\n", op.UpdatedAt) + } + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/apisecurity/discoveredoperations/root.go b/pkg/commands/apisecurity/discoveredoperations/root.go new file mode 100644 index 000000000..1d2c35792 --- /dev/null +++ b/pkg/commands/apisecurity/discoveredoperations/root.go @@ -0,0 +1,31 @@ +package discoveredoperations + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "discovered-operations" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, globals *global.Data) *RootCommand { + var c RootCommand + c.Globals = globals + c.CmdClause = parent.Command(CommandName, "Retrieve and update discovered API operations") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/apisecurity/discoveredoperations/update.go b/pkg/commands/apisecurity/discoveredoperations/update.go new file mode 100644 index 000000000..cded00901 --- /dev/null +++ b/pkg/commands/apisecurity/discoveredoperations/update.go @@ -0,0 +1,318 @@ +package discoveredoperations + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/fastly/go-fastly/v13/fastly" + "github.com/fastly/go-fastly/v13/fastly/apisecurity/operations" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a discovered API operation's status. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required . + serviceName argparser.OptionalServiceNameID + file string + operationID argparser.OptionalString + status argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update the status of discovered operation(s)") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + Required: true, + }) + c.CmdClause.Flag("operation-id", "The ID of the discovered operation (comma-separated for multiple)").Action(c.operationID.Set).StringVar(&c.operationID.Value) + c.CmdClause.Flag("file", "Update operations in bulk from a JSON file").StringVar(&c.file) + + // Optional. + c.CmdClause.Flag("status", "The new status to apply. Valid values are: 'discovered', 'ignored'").Action(c.status.Set).StringVar(&c.status.Value) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + if !c.operationID.WasSet && c.file == "" { + return fmt.Errorf("error parsing arguments: must provide either --operation-id or --file") + } + + if c.operationID.WasSet && c.file != "" { + return fmt.Errorf("error parsing arguments: cannot use both --operation-id and --file") + } + + // When using --file, status should not be provided via flag. + if c.file != "" && c.status.WasSet { + return fmt.Errorf("error parsing arguments: cannot use both --file and --status (status should be specified in the JSON file)") + } + + // When using --operation-id, status is required. + if c.operationID.WasSet && !c.status.WasSet { + return fmt.Errorf("error parsing arguments: --status is required when using --operation-id") + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + // Handle bulk mode from file. + if c.file != "" { + fileInput, err := c.readFromFile() + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + // Convert status from file to uppercase to map to API. + status, err := c.validateStatus(fileInput.Status) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + return c.executeBulkUpdate(out, serviceID, status, fileInput.OperationIDs) + } + + // Convert status to uppercase for API. + status, err := c.validateStatus(c.status.Value) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + // Handle comma-separated operation IDs. + operationIDs := strings.Split(c.operationID.Value, ",") + // Trim whitespace from each ID + for i, id := range operationIDs { + operationIDs[i] = strings.TrimSpace(id) + } + + // If multiple operation IDs, use bulk update. + if len(operationIDs) > 1 { + return c.executeBulkUpdate(out, serviceID, status, operationIDs) + } + + // Handle single operation mode. + input := operations.UpdateDiscoveredStatusInput{ + ServiceID: &serviceID, + OperationID: &c.operationID.Value, + Status: &status, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + o, err := operations.UpdateDiscoveredStatus(context.TODO(), fc, &input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Operation ID": c.operationID.Value, + "Status": c.status.Value, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + return c.printSummary(out, o) + } + + return c.printVerbose(out, o) +} + +// validateStatus converts and validates the status value. +func (c *UpdateCommand) validateStatus(statusValue string) (string, error) { + switch statusValue { + case "discovered", "DISCOVERED": + return "DISCOVERED", nil + case "ignored", "IGNORED": + return "IGNORED", nil + default: + return "", fmt.Errorf("invalid status: %s. Valid options: 'discovered', 'ignored'", statusValue) + } +} + +// executeBulkUpdate performs a bulk update operation for multiple operation IDs. +func (c *UpdateCommand) executeBulkUpdate(out io.Writer, serviceID string, status string, operationIDs []string) error { + if c.Globals.Verbose() { + fmt.Fprintf(out, "Updating %d operation(s) with status: %s\n", len(operationIDs), status) + fmt.Fprintf(out, "Operation IDs: %v\n", operationIDs) + } + + input := operations.BulkUpdateDiscoveredStatusInput{ + ServiceID: &serviceID, + OperationIDs: operationIDs, + Status: &status, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + results, err := operations.BulkUpdateDiscoveredStatus(context.TODO(), fc, &input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Status": c.status.Value, + "Count": len(operationIDs), + }) + return err + } + + if ok, err := c.WriteJSON(out, results); ok { + return err + } + + return c.printBulkResults(out, results) +} + +// UpdateFileInput represents the JSON file format for bulk update operation. +type UpdateFileInput struct { + OperationIDs []string `json:"operation_ids"` + Status string `json:"status"` +} + +// readFromFile reads operation IDs and status from a JSON file. +func (c *UpdateCommand) readFromFile() (*UpdateFileInput, error) { + path, err := filepath.Abs(c.file) + if err != nil { + return nil, err + } + + if _, err := os.Stat(path); err != nil { + return nil, err + } + + file, err := os.Open(path) /* #nosec */ + if err != nil { + return nil, err + } + defer file.Close() + + byteValue, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + var input UpdateFileInput + if err := json.Unmarshal(byteValue, &input); err != nil { + return nil, fmt.Errorf("invalid JSON format: %w", err) + } + + if len(input.OperationIDs) == 0 { + return nil, fmt.Errorf("no operation IDs found in file: %s", c.file) + } + + if input.Status == "" { + return nil, fmt.Errorf("status not specified in file: %s", c.file) + } + + return &input, nil +} + +// printBulkResults displays the results of a bulk update operation. +func (c *UpdateCommand) printBulkResults(out io.Writer, results *operations.BulkOperationResultsResponse) error { + var succeeded, failed int + for _, result := range results.Data { + if result.StatusCode >= 200 && result.StatusCode < 300 { + succeeded++ + } else { + failed++ + } + } + + text.Success(out, "Updated %d discovered operation(s)", succeeded) + + if failed > 0 { + text.Warning(out, "%d operation(s) failed to update", failed) + } + + if c.Globals.Verbose() { + text.Break(out) + tw := text.NewTable(out) + tw.AddHeader("OPERATION ID", "STATUS CODE", "RESULT") + for _, result := range results.Data { + status := "Success" + if result.StatusCode < 200 || result.StatusCode >= 300 { + status = fmt.Sprintf("Failed: %s", result.Reason) + } + tw.AddLine(result.ID, fmt.Sprintf("%d", result.StatusCode), status) + } + tw.Print() + } + + return nil +} + +// printSummary displays the discovered operation in a simple format. +func (c *UpdateCommand) printSummary(out io.Writer, op *operations.DiscoveredOperation) error { + fmt.Fprintf(out, "Updated discovered operation:\n") + fmt.Fprintf(out, " ID: %s\n", op.ID) + fmt.Fprintf(out, " Method: %s\n", op.Method) + fmt.Fprintf(out, " Domain: %s\n", op.Domain) + fmt.Fprintf(out, " Path: %s\n", op.Path) + fmt.Fprintf(out, " Status: %s\n", op.Status) + + return nil +} + +// printVerbose displays detailed information for the discovered operation. +func (c *UpdateCommand) printVerbose(out io.Writer, op *operations.DiscoveredOperation) error { + fmt.Fprintf(out, "\nUpdated Discovered Operation\n") + fmt.Fprintf(out, "\tID: %s\n", op.ID) + fmt.Fprintf(out, "\tMethod: %s\n", op.Method) + fmt.Fprintf(out, "\tDomain: %s\n", op.Domain) + fmt.Fprintf(out, "\tPath: %s\n", op.Path) + fmt.Fprintf(out, "\tStatus: %s\n", op.Status) + fmt.Fprintf(out, "\tRPS: %.2f\n", op.RPS) + if op.LastSeenAt != "" { + fmt.Fprintf(out, "\tLast Seen: %s\n", op.LastSeenAt) + } + if op.UpdatedAt != "" { + fmt.Fprintf(out, "\tUpdated At: %s\n", op.UpdatedAt) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/apisecurity/doc.go b/pkg/commands/apisecurity/doc.go new file mode 100644 index 000000000..05e619ef9 --- /dev/null +++ b/pkg/commands/apisecurity/doc.go @@ -0,0 +1,2 @@ +// Package apisecurity contains commands to manage API operations discovered for services. +package apisecurity diff --git a/pkg/commands/apisecurity/operations/addtags.go b/pkg/commands/apisecurity/operations/addtags.go new file mode 100644 index 000000000..b5a34157a --- /dev/null +++ b/pkg/commands/apisecurity/operations/addtags.go @@ -0,0 +1,210 @@ +package operations + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/fastly/go-fastly/v13/fastly" + "github.com/fastly/go-fastly/v13/fastly/apisecurity/operations" + + "github.com/fastly/kingpin" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// AddTagsCommand calls the Fastly API to add tags to operations. +type AddTagsCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + serviceName argparser.OptionalServiceNameID + tagIDs []string + + // Optional. + operationIDs []string + file string +} + +// NewAddTagsCommand returns a usable command registered under the parent. +func NewAddTagsCommand(parent argparser.Registerer, g *global.Data) *AddTagsCommand { + c := AddTagsCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("add-tags", "Add tags to operation(s)") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + Required: true, + }) + c.CmdClause.Flag("tag-ids", "Comma-separated list of tag IDs to add").Required().StringsVar(&c.tagIDs, kingpin.Separator(",")) + + // Optional. + c.CmdClause.Flag("operation-ids", "Comma-separated list of operation IDs to add tags to").StringsVar(&c.operationIDs, kingpin.Separator(",")) + c.CmdClause.Flag("file", "Add tags to operations in bulk from a JSON file").StringVar(&c.file) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *AddTagsCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + if len(c.operationIDs) == 0 && c.file == "" { + return fmt.Errorf("error parsing arguments: must provide either --operation-ids or --file") + } + + if len(c.operationIDs) > 0 && c.file != "" { + return fmt.Errorf("error parsing arguments: cannot use both --operation-ids and --file") + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + // Get operation IDs and tag IDs from file or flags + var operationIDs []string + var tagIDs []string + if c.file != "" { + fileInput, err := c.readFromFile() + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + operationIDs = fileInput.OperationIDs + tagIDs = fileInput.TagIDs + } else { + operationIDs = c.operationIDs + tagIDs = c.tagIDs + } + + if c.Globals.Verbose() { + fmt.Fprintf(out, "Adding %d tag(s) to %d operation(s)\n", len(tagIDs), len(operationIDs)) + } + + input := &operations.BulkAddTagsInput{ + ServiceID: &serviceID, + OperationIDs: operationIDs, + TagIDs: tagIDs, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + results, err := operations.BulkAddTags(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Operation Count": len(operationIDs), + "Tag Count": len(c.tagIDs), + }) + return err + } + + if ok, err := c.WriteJSON(out, results); ok { + return err + } + + return c.printResults(out, results) +} + +// AddTagsFileInput represents the JSON file format for bulk add-tags operation. +type AddTagsFileInput struct { + OperationIDs []string `json:"operation_ids"` + TagIDs []string `json:"tag_ids"` +} + +// readFromFile reads operation IDs and tag IDs from a JSON file. +func (c *AddTagsCommand) readFromFile() (*AddTagsFileInput, error) { + path, err := filepath.Abs(c.file) + if err != nil { + return nil, err + } + + if _, err := os.Stat(path); err != nil { + return nil, err + } + + file, err := os.Open(path) /* #nosec */ + if err != nil { + return nil, err + } + defer file.Close() + + byteValue, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + var input AddTagsFileInput + if err := json.Unmarshal(byteValue, &input); err != nil { + return nil, fmt.Errorf("invalid JSON format: %w", err) + } + + if len(input.OperationIDs) == 0 { + return nil, fmt.Errorf("no operation IDs found in file: %s", c.file) + } + + if len(input.TagIDs) == 0 { + return nil, fmt.Errorf("no tag IDs found in file: %s", c.file) + } + + return &input, nil +} + +// printResults displays the results of the bulk add tags operation. +func (c *AddTagsCommand) printResults(out io.Writer, results *operations.BulkOperationResultsResponse) error { + var succeeded, failed int + for _, result := range results.Data { + if result.StatusCode >= 200 && result.StatusCode < 300 { + succeeded++ + } else { + failed++ + } + } + + text.Success(out, "Added tags to %d operation(s)", succeeded) + + if failed > 0 { + text.Warning(out, "%d operation(s) failed", failed) + } + + if c.Globals.Verbose() { + text.Break(out) + tw := text.NewTable(out) + tw.AddHeader("OPERATION ID", "STATUS CODE", "RESULT") + for _, result := range results.Data { + status := "Success" + if result.StatusCode < 200 || result.StatusCode >= 300 { + status = fmt.Sprintf("Failed: %s", result.Reason) + } + tw.AddLine(result.ID, fmt.Sprintf("%d", result.StatusCode), status) + } + tw.Print() + } + + return nil +} diff --git a/pkg/commands/apisecurity/operations/create.go b/pkg/commands/apisecurity/operations/create.go new file mode 100644 index 000000000..fd4721131 --- /dev/null +++ b/pkg/commands/apisecurity/operations/create.go @@ -0,0 +1,310 @@ +package operations + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/fastly/go-fastly/v13/fastly" + "github.com/fastly/go-fastly/v13/fastly/apisecurity/operations" + + "github.com/fastly/kingpin" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create an operation. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + serviceName argparser.OptionalServiceNameID + method string + domain string + path string + + // Optional. + description string + tagIDs []string + file string +} + +// OperationInput represents a single operation to be created from JSON. +type OperationInput struct { + Method string `json:"method"` + Domain string `json:"domain"` + Path string `json:"path"` + Description string `json:"description,omitempty"` + TagIDs []string `json:"tag_ids,omitempty"` +} + +// CreateFileInput represents the JSON file format for bulk create operations. +type CreateFileInput struct { + Operations []OperationInput `json:"operations"` +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create an operation").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + Required: true, + }) + c.CmdClause.Flag("method", "The HTTP method for the operation (e.g., GET, POST, PUT)").StringVar(&c.method) + c.CmdClause.Flag("domain", "Domain for the operation").StringVar(&c.domain) + c.CmdClause.Flag("path", "The path for the operation, which may include path parameters.(e.g., /api/users)").StringVar(&c.path) + + // Optional. + c.CmdClause.Flag("description", "Description of what the operation does").StringVar(&c.description) + c.CmdClause.Flag("tag-ids", "A comma-separated array of operation tag IDs associated with this operation").StringsVar(&c.tagIDs, kingpin.Separator(",")) + c.CmdClause.Flag("file", "Create operations in bulk from a JSON file").StringVar(&c.file) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + // Validate flags + if c.file != "" && (c.method != "" || c.domain != "" || c.path != "") { + return fmt.Errorf("error parsing arguments: cannot use both --file and individual operation flags (--method, --domain, --path)") + } + + if c.file == "" && (c.method == "" || c.domain == "" || c.path == "") { + return fmt.Errorf("error parsing arguments: must provide either --file or all of --method, --domain, and --path") + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + // Handle bulk mode from file + if c.file != "" { + return c.createFromFile(serviceID, out) + } + + // Handle single operation mode + input := c.constructInput(serviceID) + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + o, err := operations.Create(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Method": c.method, + "Domain": c.domain, + "Path": c.path, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.Success(out, "Created operation %s %s%s (ID: %s)", strings.ToUpper(o.Method), o.Domain, o.Path, o.ID) + if c.description != "" { + fmt.Fprintf(out, "\nDescription: %s\n", o.Description) + } + if len(o.TagIDs) > 0 { + fmt.Fprintf(out, "Tags: %d associated\n", len(o.TagIDs)) + } + + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput(serviceID string) *operations.CreateInput { + input := &operations.CreateInput{ + ServiceID: &serviceID, + Method: &c.method, + Domain: &c.domain, + Path: &c.path, + } + + if c.description != "" { + input.Description = &c.description + } + + if len(c.tagIDs) > 0 { + input.TagIDs = c.tagIDs + } + + return input +} + +// createFromFile creates operations in bulk from a newline-delimited JSON file. +func (c *CreateCommand) createFromFile(serviceID string, out io.Writer) error { + ops, err := c.readOperationsFromFile() + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if c.Globals.Verbose() { + fmt.Fprintf(out, "Creating %d operation(s) from file\n", len(ops)) + } + + type result struct { + Operation *operations.Operation + Error error + } + + results := make([]result, 0, len(ops)) + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + for _, op := range ops { + input := &operations.CreateInput{ + ServiceID: &serviceID, + Method: &op.Method, + Domain: &op.Domain, + Path: &op.Path, + Description: &op.Description, + TagIDs: op.TagIDs, + } + + o, err := operations.Create(context.TODO(), fc, input) + results = append(results, result{ + Operation: o, + Error: err, + }) + } + + // Count successes and failures + var succeeded, failed int + for _, r := range results { + if r.Error == nil { + succeeded++ + } else { + failed++ + } + } + + if c.JSONOutput.Enabled { + type jsonResult struct { + Success int `json:"success"` + Failed int `json:"failed"` + Operations []*operations.Operation `json:"operations,omitempty"` + Errors []string `json:"errors,omitempty"` + } + + jr := jsonResult{ + Success: succeeded, + Failed: failed, + } + + for _, r := range results { + if r.Error == nil { + jr.Operations = append(jr.Operations, r.Operation) + } else { + jr.Errors = append(jr.Errors, r.Error.Error()) + } + } + + _, err := c.WriteJSON(out, jr) + return err + } + + text.Success(out, "Created %d operation(s)", succeeded) + + if failed > 0 { + text.Warning(out, "%d operation(s) failed to create", failed) + } + + if c.Globals.Verbose() { + text.Break(out) + tw := text.NewTable(out) + tw.AddHeader("METHOD", "DOMAIN", "PATH", "RESULT") + for i, r := range results { + status := "Success" + if r.Error != nil { + status = fmt.Sprintf("Failed: %s", r.Error.Error()) + } + op := ops[i] + tw.AddLine(strings.ToUpper(op.Method), op.Domain, op.Path, status) + } + tw.Print() + } + + if failed > 0 { + return fmt.Errorf("%d operation(s) failed to create", failed) + } + + return nil +} + +// readOperationsFromFile reads operations from a JSON file. +func (c *CreateCommand) readOperationsFromFile() ([]OperationInput, error) { + path, err := filepath.Abs(c.file) + if err != nil { + return nil, err + } + + if _, err := os.Stat(path); err != nil { + return nil, err + } + + file, err := os.Open(path) /* #nosec */ + if err != nil { + return nil, err + } + defer file.Close() + + byteValue, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + var input CreateFileInput + if err := json.Unmarshal(byteValue, &input); err != nil { + return nil, fmt.Errorf("invalid JSON format: %w", err) + } + + if len(input.Operations) == 0 { + return nil, fmt.Errorf("no operations found in file: %s", c.file) + } + + // Validate required fields + for i, op := range input.Operations { + if op.Method == "" || op.Domain == "" || op.Path == "" { + return nil, fmt.Errorf("operation %d: missing required fields (method, domain, path)", i+1) + } + } + + return input.Operations, nil +} diff --git a/pkg/commands/apisecurity/operations/delete.go b/pkg/commands/apisecurity/operations/delete.go new file mode 100644 index 000000000..1627e97d4 --- /dev/null +++ b/pkg/commands/apisecurity/operations/delete.go @@ -0,0 +1,101 @@ +package operations + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v13/fastly" + "github.com/fastly/go-fastly/v13/fastly/apisecurity/operations" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete an operation. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + serviceName argparser.OptionalServiceNameID + operationID string +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete an operation").Alias("remove") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + Required: true, + }) + c.CmdClause.Flag("operation-id", "The unique identifier of the operation").Required().StringVar(&c.operationID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + input := &operations.DeleteInput{ + ServiceID: &serviceID, + OperationID: &c.operationID, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + err = operations.Delete(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Operation ID": c.operationID, + }) + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ServiceID string `json:"service_id"` + OperationID string `json:"operation_id"` + Deleted bool `json:"deleted"` + }{ + serviceID, + c.operationID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted operation '%s'", c.operationID) + return nil +} diff --git a/pkg/commands/apisecurity/operations/describe.go b/pkg/commands/apisecurity/operations/describe.go new file mode 100644 index 000000000..c865f758c --- /dev/null +++ b/pkg/commands/apisecurity/operations/describe.go @@ -0,0 +1,117 @@ +package operations + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/fastly/go-fastly/v13/fastly" + "github.com/fastly/go-fastly/v13/fastly/apisecurity/operations" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// DescribeCommand calls the Fastly API to describe an operation. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + serviceName argparser.OptionalServiceNameID + operationID string +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Retrieve a single operation").Alias("get") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + Required: true, + }) + c.CmdClause.Flag("operation-id", "The unique identifier of the operation").Required().StringVar(&c.operationID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + input := &operations.DescribeInput{ + ServiceID: &serviceID, + OperationID: &c.operationID, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + o, err := operations.Describe(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Operation ID": c.operationID, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.print(out, o) +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, o *operations.Operation) error { + fmt.Fprintf(out, "\nOperation ID: %s\n", o.ID) + fmt.Fprintf(out, "Method: %s\n", strings.ToUpper(o.Method)) + fmt.Fprintf(out, "Domain: %s\n", o.Domain) + fmt.Fprintf(out, "Path: %s\n", o.Path) + fmt.Fprintf(out, "Description: %s\n", o.Description) + fmt.Fprintf(out, "Status: %s\n", o.Status) + fmt.Fprintf(out, "Tag IDs: %s\n", strings.Join(o.TagIDs, ", ")) + fmt.Fprintf(out, "RPS: %.2f\n\n", o.RPS) + + if o.CreatedAt != "" { + fmt.Fprintf(out, "Created At: %s\n", o.CreatedAt) + } + + if o.UpdatedAt != "" { + fmt.Fprintf(out, "Updated At: %s\n", o.UpdatedAt) + } + + if o.LastSeenAt != "" { + fmt.Fprintf(out, "Last Seen At: %s\n", o.LastSeenAt) + } + + return nil +} diff --git a/pkg/commands/apisecurity/operations/doc.go b/pkg/commands/apisecurity/operations/doc.go new file mode 100644 index 000000000..4eb7a3bda --- /dev/null +++ b/pkg/commands/apisecurity/operations/doc.go @@ -0,0 +1,2 @@ +// Package operations contains commands to manage operations associated with services. +package operations diff --git a/pkg/commands/apisecurity/operations/list.go b/pkg/commands/apisecurity/operations/list.go new file mode 100644 index 000000000..80dbd6e7c --- /dev/null +++ b/pkg/commands/apisecurity/operations/list.go @@ -0,0 +1,198 @@ +package operations + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/fastly/go-fastly/v13/fastly" + "github.com/fastly/go-fastly/v13/fastly/apisecurity/operations" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list operations. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + input operations.ListOperationsInput + serviceName argparser.OptionalServiceNameID + + // Optional. + domain argparser.OptionalString + method argparser.OptionalString + path argparser.OptionalString + tagID argparser.OptionalString + page argparser.OptionalInt + perPage argparser.OptionalInt +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List operations") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + Required: true, + }) + + // Optional. + c.CmdClause.Flag("domain", "Filters operations by domain (exact match)").Action(c.domain.Set).StringVar(&c.domain.Value) + c.CmdClause.Flag("method", "Filters operations by HTTP method (e.g., GET, POST, PUT)").Action(c.method.Set).StringVar(&c.method.Value) + c.CmdClause.Flag("path", "Filters operations by path (exact match)").Action(c.path.Set).StringVar(&c.path.Value) + c.CmdClause.Flag("tag-id", "Filters operations by tag ID").Action(c.tagID.Set).StringVar(&c.tagID.Value) + c.CmdClause.Flag("page", "Page number for pagination (0-indexed)").Action(c.page.Set).IntVar(&c.page.Value) + c.CmdClause.Flag("per-page", "Number of items per page (default: 100)").Action(c.perPage.Set).IntVar(&c.perPage.Value) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + c.input.ServiceID = &serviceID + + if c.domain.WasSet { + c.input.Domain = []string{c.domain.Value} + } + if c.method.WasSet { + c.input.Method = []string{c.method.Value} + } + if c.path.WasSet { + c.input.Path = &c.path.Value + } + if c.tagID.WasSet { + c.input.TagID = &c.tagID.Value + } + + // Set pagination parameters + if c.page.WasSet { + c.input.Page = &c.page.Value + } + + if c.perPage.WasSet { + c.input.Limit = &c.perPage.Value + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + o, err := operations.ListOperations(context.TODO(), fc, &c.input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Domain": c.domain.Value, + "Method": c.method.Value, + "Path": c.path.Value, + "Tag ID": c.tagID.Value, + "Page": c.page.Value, + "Per Page": c.perPage.Value, + }) + return err + } + + if o == nil { + o = &operations.Operations{ + Data: []operations.Operation{}, + } + } + + if ok, err := c.WriteJSON(out, o.Data); ok { + return err + } + + if !c.Globals.Verbose() { + return c.printSummary(out, o.Data) + } + + return c.printVerbose(out, o.Data) +} + +// printSummary displays the operations in a table format. +func (c *ListCommand) printSummary(out io.Writer, o []operations.Operation) error { + tw := text.NewTable(out) + tw.AddHeader("ID", "METHOD", "DOMAIN", "PATH", "DESCRIPTION", "TAGS") + for _, op := range o { + description := op.Description + if len(description) > 50 { + description = description[:47] + "..." + } + tags := fmt.Sprintf("%d", len(op.TagIDs)) + tw.AddLine( + op.ID, + strings.ToUpper(op.Method), + op.Domain, + op.Path, + description, + tags, + ) + } + tw.Print() + + return nil +} + +// printVerbose displays detailed information for each operation. +func (c *ListCommand) printVerbose(out io.Writer, o []operations.Operation) error { + for i, op := range o { + fmt.Fprintf(out, "\nOperation %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\tID: %s\n", op.ID) + fmt.Fprintf(out, "\tMethod: %s\n", strings.ToUpper(op.Method)) + fmt.Fprintf(out, "\tDomain: %s\n", op.Domain) + fmt.Fprintf(out, "\tPath: %s\n", op.Path) + if op.Description != "" { + fmt.Fprintf(out, "\tDescription: %s\n", op.Description) + } + if op.Status != "" { + fmt.Fprintf(out, "\tStatus: %s\n", op.Status) + } + if len(op.TagIDs) > 0 { + fmt.Fprintf(out, "\tTag IDs: %s\n", strings.Join(op.TagIDs, ", ")) + } + if op.RPS > 0 { + fmt.Fprintf(out, "\tRPS: %.2f\n", op.RPS) + } + if op.CreatedAt != "" { + fmt.Fprintf(out, "\tCreated At: %s\n", op.CreatedAt) + } + if op.UpdatedAt != "" { + fmt.Fprintf(out, "\tUpdated At: %s\n", op.UpdatedAt) + } + if op.LastSeenAt != "" { + fmt.Fprintf(out, "\tLast Seen: %s\n", op.LastSeenAt) + } + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/apisecurity/operations/operations_test.go b/pkg/commands/apisecurity/operations/operations_test.go new file mode 100644 index 000000000..f995c00b4 --- /dev/null +++ b/pkg/commands/apisecurity/operations/operations_test.go @@ -0,0 +1,287 @@ +package operations_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/fastly/go-fastly/v13/fastly/apisecurity/operations" + + apisecurity "github.com/fastly/cli/pkg/commands/apisecurity" + root "github.com/fastly/cli/pkg/commands/apisecurity/operations" + "github.com/fastly/cli/pkg/testutil" +) + +const ( + serviceID = "test-service-id" +) + +var ( + listResponse = operations.Operations{ + Data: []operations.Operation{ + { + ID: "test-operation-id", + Method: "DELETE", + Domain: "www.foo.com", + Path: "/api/v1/users/{var1}", + Description: "Retrieve user information", + Status: "SAVED", + RPS: 10.5, + CreatedAt: "2026-02-02T14:27:16Z", + UpdatedAt: "2026-02-02T14:33:19Z", + TagIDs: []string{}, + }, + { + ID: "test-operation-id-2", + Method: "POST", + Domain: "www.foo.com", + Path: "/api/v1/users", + Description: "Create a new user", + Status: "SAVED", + RPS: 5.2, + CreatedAt: "2026-02-01T10:00:00Z", + UpdatedAt: "2026-02-01T10:30:00Z", + TagIDs: []string{"tag-1", "tag-2"}, + }, + }, + Meta: operations.Meta{ + Limit: 2, + Total: 2, + }, + } + + listResponseJSON = testutil.GenJSON(listResponse) + + listOperationsOutput = strings.TrimSpace(` +ID METHOD DOMAIN PATH DESCRIPTION TAGS +test-operation-id DELETE www.foo.com /api/v1/users/{var1} Retrieve user information 0 +test-operation-id-2 POST www.foo.com /api/v1/users Create a new user 2 +`) + "\n" + + listOperationsVerboseOutput = strings.TrimSpace(` +Operation 1/2 + ID: test-operation-id + Method: DELETE + Domain: www.foo.com + Path: /api/v1/users/{var1} + Description: Retrieve user information + Status: SAVED + RPS: 10.50 + Created At: 2026-02-02T14:27:16Z + Updated At: 2026-02-02T14:33:19Z + +Operation 2/2 + ID: test-operation-id-2 + Method: POST + Domain: www.foo.com + Path: /api/v1/users + Description: Create a new user + Status: SAVED + Tag IDs: tag-1, tag-2 + RPS: 5.20 + Created At: 2026-02-01T10:00:00Z + Updated At: 2026-02-01T10:30:00Z +`) + "\n\n" +) + +func TestListCommand(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --service-id flag", + Args: "", + WantError: "error parsing arguments: required flag --service-id not provided", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--service-id %s", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listOperationsOutput, + }, + { + Name: "validate --json flag", + Args: fmt.Sprintf("--service-id %s --json", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(listResponseJSON)), + }, + }, + }, + WantOutput: string(listResponseJSON), + }, + { + Name: "validate --verbose output", + Args: fmt.Sprintf("--service-id %s --verbose", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listOperationsVerboseOutput, + }, + { + Name: "validate API error", + Args: fmt.Sprintf("--service-id %s", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + Body: io.NopCloser(strings.NewReader(`{"detail":"Internal Server Error"}`)), + }, + }, + }, + WantError: "500", + }, + } + + testutil.RunCLIScenarios(t, []string{apisecurity.CommandName, root.CommandName, "list"}, scenarios) +} + +func TestListCommandWithPagination(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate --page flag", + Args: fmt.Sprintf("--service-id %s --page 1", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listOperationsOutput, + }, + { + Name: "validate --per-page flag", + Args: fmt.Sprintf("--service-id %s --per-page 50", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listOperationsOutput, + }, + { + Name: "validate --page and --per-page together", + Args: fmt.Sprintf("--service-id %s --page 2 --per-page 25", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listOperationsOutput, + }, + } + + testutil.RunCLIScenarios(t, []string{apisecurity.CommandName, root.CommandName, "list"}, scenarios) +} + +func TestListCommandWithFilters(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate --domain filter", + Args: fmt.Sprintf("--service-id %s --domain www.foo.com", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listOperationsOutput, + }, + { + Name: "validate --method filter", + Args: fmt.Sprintf("--service-id %s --method DELETE", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listOperationsOutput, + }, + { + Name: "validate --path filter", + Args: fmt.Sprintf("--service-id %s --path /api/v1/users", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listOperationsOutput, + }, + { + Name: "validate --tag-id filter", + Args: fmt.Sprintf("--service-id %s --tag-id tag-1", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), + }, + }, + }, + WantOutput: listOperationsOutput, + }, + { + Name: "validate empty results", + Args: fmt.Sprintf("--service-id %s", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(operations.Operations{ + Data: []operations.Operation{}, + Meta: operations.Meta{ + Limit: 0, + Total: 0, + }, + }))), + }, + }, + }, + }, + } + + testutil.RunCLIScenarios(t, []string{apisecurity.CommandName, root.CommandName, "list"}, scenarios) +} diff --git a/pkg/commands/apisecurity/operations/root.go b/pkg/commands/apisecurity/operations/root.go new file mode 100644 index 000000000..ff3bfd5fb --- /dev/null +++ b/pkg/commands/apisecurity/operations/root.go @@ -0,0 +1,31 @@ +package operations + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "operations" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, globals *global.Data) *RootCommand { + var c RootCommand + c.Globals = globals + c.CmdClause = parent.Command(CommandName, "Manage operations associated with services") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/apisecurity/operations/update.go b/pkg/commands/apisecurity/operations/update.go new file mode 100644 index 000000000..32823da10 --- /dev/null +++ b/pkg/commands/apisecurity/operations/update.go @@ -0,0 +1,134 @@ +package operations + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/fastly/go-fastly/v13/fastly" + "github.com/fastly/go-fastly/v13/fastly/apisecurity/operations" + + "github.com/fastly/kingpin" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update an operation. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + serviceName argparser.OptionalServiceNameID + operationID string + + // Optional. + description argparser.OptionalString + tagIDs argparser.OptionalStringSlice +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update an operation") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + Required: true, + }) + c.CmdClause.Flag("operation-id", "The unique identifier of the operation").Required().StringVar(&c.operationID) + + // Optional. + c.CmdClause.Flag("description", "Updated description of what the operation does").Action(c.description.Set).StringVar(&c.description.Value) + c.CmdClause.Flag("tag-ids", "Comma-separated list of tag IDs to associate with the operation").Action(c.tagIDs.Set).StringsVar(&c.tagIDs.Value, kingpin.Separator(",")) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + if !c.description.WasSet && !c.tagIDs.WasSet { + return fmt.Errorf("error parsing arguments: must provide at least one field to update (--description or --tag-ids)") + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + input := c.constructInput(serviceID) + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + o, err := operations.Update(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Operation ID": c.operationID, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.Success(out, "Updated operation %s %s%s (ID: %s)", strings.ToUpper(o.Method), o.Domain, o.Path, o.ID) + + if c.Globals.Verbose() { + fmt.Fprintln(out) + if o.Description != "" { + fmt.Fprintf(out, "Description: %s\n", o.Description) + } + if len(o.TagIDs) > 0 { + fmt.Fprintf(out, "Tags: %d associated\n", len(o.TagIDs)) + } + if o.UpdatedAt != "" { + fmt.Fprintf(out, "Updated At: %s\n", o.UpdatedAt) + } + } + + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput(serviceID string) *operations.UpdateInput { + input := &operations.UpdateInput{ + ServiceID: &serviceID, + OperationID: &c.operationID, + } + + if c.description.WasSet { + input.Description = &c.description.Value + } + + if c.tagIDs.WasSet { + input.TagIDs = c.tagIDs.Value + } + + return input +} diff --git a/pkg/commands/apisecurity/root.go b/pkg/commands/apisecurity/root.go new file mode 100644 index 000000000..c086da107 --- /dev/null +++ b/pkg/commands/apisecurity/root.go @@ -0,0 +1,31 @@ +package apisecurity + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "apisecurity" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, globals *global.Data) *RootCommand { + var c RootCommand + c.Globals = globals + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly API security operations") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index d757bb2f6..6b7b4e5f5 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -49,6 +49,9 @@ import ( aliasvclcondition "github.com/fastly/cli/pkg/commands/alias/vcl/condition" aliasvclcustom "github.com/fastly/cli/pkg/commands/alias/vcl/custom" aliasvclsnippet "github.com/fastly/cli/pkg/commands/alias/vcl/snippet" + "github.com/fastly/cli/pkg/commands/apisecurity" + "github.com/fastly/cli/pkg/commands/apisecurity/discoveredoperations" + "github.com/fastly/cli/pkg/commands/apisecurity/operations" "github.com/fastly/cli/pkg/commands/authtoken" "github.com/fastly/cli/pkg/commands/compute" "github.com/fastly/cli/pkg/commands/compute/computeacl" @@ -180,6 +183,17 @@ func Define( // nolint:revive // function-length // beginning of the list of commands. ssoCmdRoot := sso.NewRootCommand(app, data) + apisecurityRoot := apisecurity.NewRootCommand(app, data) + discoveredoperationsRoot := discoveredoperations.NewRootCommand(apisecurityRoot.CmdClause, data) + discoveredoperationsList := discoveredoperations.NewListCommand(discoveredoperationsRoot.CmdClause, data) + discoveredoperationsUpdate := discoveredoperations.NewUpdateCommand(discoveredoperationsRoot.CmdClause, data) + operationsRoot := operations.NewRootCommand(apisecurityRoot.CmdClause, data) + operationsList := operations.NewListCommand(operationsRoot.CmdClause, data) + operationsCreate := operations.NewCreateCommand(operationsRoot.CmdClause, data) + operationsDescribe := operations.NewDescribeCommand(operationsRoot.CmdClause, data) + operationsUpdate := operations.NewUpdateCommand(operationsRoot.CmdClause, data) + operationsDelete := operations.NewDeleteCommand(operationsRoot.CmdClause, data) + operationsAddTags := operations.NewAddTagsCommand(operationsRoot.CmdClause, data) authtokenCmdRoot := authtoken.NewRootCommand(app, data) authtokenCreate := authtoken.NewCreateCommand(authtokenCmdRoot.CmdClause, data) authtokenDelete := authtoken.NewDeleteCommand(authtokenCmdRoot.CmdClause, data) @@ -1018,6 +1032,17 @@ func Define( // nolint:revive // function-length return []argparser.Command{ shellcompleteCmdRoot, + apisecurityRoot, + discoveredoperationsRoot, + discoveredoperationsList, + discoveredoperationsUpdate, + operationsRoot, + operationsList, + operationsCreate, + operationsDescribe, + operationsUpdate, + operationsDelete, + operationsAddTags, authtokenCmdRoot, authtokenCreate, authtokenDelete, From 9c652fc6026c876df240de7813273149a26bad78 Mon Sep 17 00:00:00 2001 From: Richard Carillo Date: Fri, 13 Mar 2026 15:15:05 -0400 Subject: [PATCH 2/6] build(go-fastly): bump go-fastly to v13.1.0 --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index e7fcf0b22..f417fed93 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/pelletier/go-toml v1.9.5 github.com/segmentio/textio v1.2.0 github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e - golang.org/x/sys v0.41.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.40.0 ) @@ -81,6 +81,6 @@ require ( require ( 4d63.com/optional v0.2.0 - github.com/fastly/go-fastly/v13 v13.0.0 + github.com/fastly/go-fastly/v13 v13.1.0 github.com/mitchellh/go-ps v1.0.0 ) diff --git a/go.sum b/go.sum index deada188c..365f3dd2c 100644 --- a/go.sum +++ b/go.sum @@ -27,8 +27,8 @@ github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj6 github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 h1:aYo8nnk3ojoQkP5iErif5Xxv0Mo0Ga/FR5+ffl/7+Nk= github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= -github.com/fastly/go-fastly/v13 v13.0.0 h1:n7i0CPt4YtC5LxlvAcDtKOfwnmouSDbKAJAL8ZYRBdQ= -github.com/fastly/go-fastly/v13 v13.0.0/go.mod h1:tkxl8FPP6R+SyyQfFrmtIO1OuQT3oWAh2rpjI41e1hs= +github.com/fastly/go-fastly/v13 v13.1.0 h1:aVYybDdr23LNKirfJSafY0GOIsTpXHrlX4W2gDwq0nw= +github.com/fastly/go-fastly/v13 v13.1.0/go.mod h1:9uKtXpskI/EDlKmTq/oPbjFCztoGsHaLcRobm7qda5Q= github.com/fastly/kingpin v2.1.12-0.20191105091915-95d230a53780+incompatible h1:FhrXlfhgGCS+uc6YwyiFUt04alnjpoX7vgDKJxS6Qbk= github.com/fastly/kingpin v2.1.12-0.20191105091915-95d230a53780+incompatible/go.mod h1:U8UynVoU1SQaqD2I4ZqgYd5lx3A1ipQYn4aSt2Y5h6c= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= @@ -191,8 +191,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= From 83b2abbbd063460f169a740fa5fe3e3d7a13ac7a Mon Sep 17 00:00:00 2001 From: Richard Carillo Date: Fri, 13 Mar 2026 15:20:58 -0400 Subject: [PATCH 3/6] build(deps): bumped deps to match main --- go.mod | 14 +++++++------- go.sum | 28 ++++++++++++++-------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index f417fed93..a4a6c1767 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/segmentio/textio v1.2.0 github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.40.0 + golang.org/x/term v0.41.0 ) require ( @@ -35,7 +35,7 @@ require ( github.com/theckman/yacspin v0.13.12 golang.org/x/crypto v0.48.0 golang.org/x/exp v0.0.0-20260112195511-716be5621a96 - golang.org/x/mod v0.33.0 + golang.org/x/mod v0.34.0 ) require ( @@ -64,17 +64,17 @@ require ( github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect github.com/nwaples/rardecode v1.1.3 // indirect github.com/peterhellberg/link v1.2.0 // indirect - github.com/pierrec/lz4/v4 v4.1.25 // indirect + github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/ulikunitz/xz v0.5.15 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect golang.org/x/net v0.51.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/text v0.34.0 + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.35.0 gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 365f3dd2c..148fb8ab5 100644 --- a/go.sum +++ b/go.sum @@ -85,8 +85,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= -github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo= github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= @@ -111,8 +111,8 @@ github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCko github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c= github.com/peterhellberg/link v1.2.0/go.mod h1:gYfAh+oJgQu2SrZHg5hROVRQe1ICoK0/HHJTcE0edxc= github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= -github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= @@ -161,8 +161,8 @@ golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7 golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -170,13 +170,13 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -198,16 +198,16 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= From 1856e5d1da206e51d3c5dc4243ef90d48e473a72 Mon Sep 17 00:00:00 2001 From: Richard Carillo Date: Fri, 13 Mar 2026 15:35:46 -0400 Subject: [PATCH 4/6] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f33df03e..af3f2b4c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ - feat(stats): add `stats usage` subcommand for bandwidth/request usage, with `--by-service` breakdown. [#1678](https://github.com/fastly/cli/pull/1678) - 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(apisecurity/discoveredoperations): add support for 'list' and 'update' support for 'API discovery'. [#1689](https://github.com/fastly/cli/pull/1689) +- feat(apisecurity/operations): add CRUD support for 'API security operations'. [#1689](https://github.com/fastly/cli/pull/1689) ### Dependencies: - build(deps): `golang.org/x/net` from 0.50.0 to 0.51.0 ([#1674](https://github.com/fastly/cli/pull/1674)) From 7359592e275239559ec2e73021fe637d72fa9d78 Mon Sep 17 00:00:00 2001 From: Richard Carillo Date: Fri, 13 Mar 2026 15:40:43 -0400 Subject: [PATCH 5/6] Fix commands.go syntax --- pkg/commands/commands.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index 98a1ccd7d..db7612589 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -1081,6 +1081,7 @@ func Define( // nolint:revive // function-length shellcompleteCmdRoot, } cmds = append(cmds, authCommands...) + cmds = append(cmds, authtokenCommands...) cmds = append(cmds, []argparser.Command{ apisecurityRoot, discoveredoperationsRoot, @@ -1093,9 +1094,6 @@ func Define( // nolint:revive // function-length operationsUpdate, operationsDelete, operationsAddTags, - }...) - cmds = append(cmds, authtokenCommands...) - cmds = append(cmds, []argparser.Command{ computeCmdRoot, computeACLCmdRoot, computeACLCreate, From 7e7619cb3b1ef44d1164436df19d58cc2cd14c67 Mon Sep 17 00:00:00 2001 From: Richard Carillo Date: Fri, 13 Mar 2026 16:09:06 -0400 Subject: [PATCH 6/6] fix tests removed meta from checks and updated run_test.go --- pkg/app/run_test.go | 1 + .../discoveredoperations/discoveredoperations_test.go | 4 ++-- pkg/commands/apisecurity/operations/operations_test.go | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/app/run_test.go b/pkg/app/run_test.go index e3e53cea9..23768b193 100644 --- a/pkg/app/run_test.go +++ b/pkg/app/run_test.go @@ -60,6 +60,7 @@ complete -F _fastly_bash_autocomplete fastly Args: "--completion-bash", WantOutput: `help auth +apisecurity compute config config-store diff --git a/pkg/commands/apisecurity/discoveredoperations/discoveredoperations_test.go b/pkg/commands/apisecurity/discoveredoperations/discoveredoperations_test.go index 9c8e9bf83..f5a60fe2b 100644 --- a/pkg/commands/apisecurity/discoveredoperations/discoveredoperations_test.go +++ b/pkg/commands/apisecurity/discoveredoperations/discoveredoperations_test.go @@ -87,7 +87,7 @@ POST example.com /api/users SAVED 5.20 2026-03-10T12:00:00Z listDiscoveredOperationsVerboseOutput = strings.TrimSpace(` Fastly API endpoint: https://api.fastly.com -Fastly API token provided via config file (profile: user) +Fastly API token provided via config file (auth: user) Service ID (via --service-id): test-service-id @@ -161,7 +161,7 @@ func TestListCommand(t *testing.T) { }, }, }, - WantOutput: string(listResponseJSON), + WantOutput: string(testutil.GenJSON(listResponse.Data)), }, { Name: "validate invalid status", diff --git a/pkg/commands/apisecurity/operations/operations_test.go b/pkg/commands/apisecurity/operations/operations_test.go index f995c00b4..5732151a5 100644 --- a/pkg/commands/apisecurity/operations/operations_test.go +++ b/pkg/commands/apisecurity/operations/operations_test.go @@ -120,7 +120,7 @@ func TestListCommand(t *testing.T) { }, }, }, - WantOutput: string(listResponseJSON), + WantOutput: string(testutil.GenJSON(listResponse.Data)), }, { Name: "validate --verbose output",