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)) 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 new file mode 100644 index 000000000..f5a60fe2b --- /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 (auth: 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(testutil.GenJSON(listResponse.Data)), + }, + { + 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..5732151a5 --- /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(testutil.GenJSON(listResponse.Data)), + }, + { + 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 0f8aacd14..db7612589 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -51,6 +51,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" authcmd "github.com/fastly/cli/pkg/commands/auth" "github.com/fastly/cli/pkg/commands/authtoken" "github.com/fastly/cli/pkg/commands/compute" @@ -212,6 +215,19 @@ func Define( // nolint:revive // function-length authtokenDescribe, authtokenList, } } + + // API Security commands + 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) computeCmdRoot := compute.NewRootCommand(app, data) computeACLCmdRoot := computeacl.NewRootCommand(computeCmdRoot.CmdClause, data) computeACLCreate := computeacl.NewCreateCommand(computeACLCmdRoot.CmdClause, data) @@ -1067,6 +1083,17 @@ func Define( // nolint:revive // function-length cmds = append(cmds, authCommands...) cmds = append(cmds, authtokenCommands...) cmds = append(cmds, []argparser.Command{ + apisecurityRoot, + discoveredoperationsRoot, + discoveredoperationsList, + discoveredoperationsUpdate, + operationsRoot, + operationsList, + operationsCreate, + operationsDescribe, + operationsUpdate, + operationsDelete, + operationsAddTags, computeCmdRoot, computeACLCmdRoot, computeACLCreate,