diff --git a/.gitignore b/.gitignore index b2df531..2df31c7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ bin/ /.agents/plans/ HANDOVER.md .vscode/ +.worktrees/ diff --git a/cmd/stackdome/addon_test.go b/cmd/stackdome/addon_test.go index 36c5361..e39e770 100644 --- a/cmd/stackdome/addon_test.go +++ b/cmd/stackdome/addon_test.go @@ -14,49 +14,36 @@ import ( "time" "github.com/Stackdome/stackdome-cli/internal/config" - "github.com/spf13/cobra" ) const postgresAddonJSON = `{"id":"pg-1","name":"demo","spec":{"version":{"major":16},"instances":{"count":1},"storage":{"size":"10Gi"},"databases":[{"name":"demo"}]},"status":{"state":"Pending"}}` -func TestRootRegistersPostgresShortcutWithSameHelpAndCreateFlags(t *testing.T) { +func TestRootRegistersPostgresVerbFirstCommands(t *testing.T) { root := newRootCmd() - shortcut, _, err := root.Find([]string{"postgres"}) - if err != nil || shortcut == root || shortcut.CommandPath() != "stackdome postgres" { - t.Fatalf("find top-level postgres = %v, %v; want registered shortcut", shortcut, err) + create, _, err := root.Find([]string{"create", "postgres-addon", "demo"}) + if err != nil || create.CommandPath() != "stackdome create postgres-addon" { + t.Fatalf("find create postgres-addon = %v, %v", create, err) } - legacy, _, err := root.Find([]string{"addon", "postgres"}) - if err != nil || legacy == root || legacy.CommandPath() != "stackdome addon postgres" { - t.Fatalf("find legacy postgres = %v, %v; want retained path", legacy, err) - } - - for _, command := range []*cobra.Command{shortcut, legacy} { - create, _, err := command.Find([]string{"create"}) - if err != nil { - t.Fatalf("find %s create: %v", command.CommandPath(), err) - } - for _, flag := range []string{"database", "superuser", "version", "instances", "storage", "wait", "timeout"} { - if create.Flags().Lookup(flag) == nil { - t.Errorf("%s create missing --%s", command.CommandPath(), flag) - } + for _, flag := range []string{"database", "superuser", "version", "instances", "storage", "wait", "timeout"} { + if create.Flags().Lookup(flag) == nil { + t.Errorf("%s missing --%s", create.CommandPath(), flag) } } - - for _, args := range [][]string{{"postgres", "--help"}, {"addon", "postgres", "--help"}} { - var stdout, stderr bytes.Buffer - code := runWithWriters(args, &stdout, &stderr) - if code != 0 { - t.Fatalf("%v help exit = %d, stderr = %s", args, code, stderr.String()) - } - for _, subcommand := range []string{"create", "list", "info", "delete", "credentials", "backup", "backups"} { - if !strings.Contains(stdout.String(), subcommand) { - t.Errorf("%v help omitted %q:\n%s", args, subcommand, stdout.String()) - } + for _, path := range [][]string{ + {"get", "postgres-addons"}, {"list", "postgres-addons"}, + {"get", "postgres-addon", "demo"}, {"describe", "postgres-addon", "demo"}, + {"get", "postgres-credentials", "demo", "app"}, + {"get", "postgres-backups", "demo"}, {"list", "postgres-backups", "demo"}, + {"backup", "postgres-addon", "demo"}, {"delete", "postgres-addon", "demo"}, + } { + cmd, _, err := root.Find(path) + if err != nil || !cmd.Runnable() { + t.Errorf("path %v did not resolve to a runnable command: %v, %v", path, cmd, err) } } } -func TestPostgresShortcutAndLegacyPathsRouteCreateAndListEquivalently(t *testing.T) { +func TestPostgresGetAndListRouteEquivalently(t *testing.T) { type request struct { method string path string @@ -84,48 +71,29 @@ func TestPostgresShortcutAndLegacyPathsRouteCreateAndListEquivalently(t *testing defer server.Close() configurePostgresCLI(t, server.URL) - paths := [][]string{{"postgres"}, {"addon", "postgres"}} - var createOutputs, listOutputs []string - for _, path := range paths { - createArgs := append(append([]string{}, path...), "create", "demo", "--database", "demo", "--version", "16", "--instances", "1", "--storage", "10Gi", "-o", "json") - stdout, stderr, code := runPostgresCLI(createArgs) - if code != 0 { - t.Fatalf("%v exit = %d, stderr = %s", createArgs, code, stderr) - } - createOutputs = append(createOutputs, stdout) - - listArgs := append(append([]string{}, path...), "list", "-o", "json") - stdout, stderr, code = runPostgresCLI(listArgs) + var listOutputs []string + for _, listArgs := range [][]string{{"get", "postgres-addons", "-o", "json"}, {"list", "postgres-addons", "-o", "json"}} { + stdout, stderr, code := runPostgresCLI(listArgs) if code != 0 { t.Fatalf("%v exit = %d, stderr = %s", listArgs, code, stderr) } listOutputs = append(listOutputs, stdout) } - if createOutputs[0] != createOutputs[1] { - t.Errorf("create outputs differ:\nshortcut: %s\nlegacy: %s", createOutputs[0], createOutputs[1]) - } if listOutputs[0] != listOutputs[1] { - t.Errorf("list outputs differ:\nshortcut: %s\nlegacy: %s", listOutputs[0], listOutputs[1]) + t.Errorf("get/list outputs differ:\nget: %s\nlist: %s", listOutputs[0], listOutputs[1]) } mu.Lock() defer mu.Unlock() - if len(requests) != 4 { - t.Fatalf("requests = %#v, want four", requests) + if len(requests) != 2 { + t.Fatalf("requests = %#v, want two", requests) } const endpoint = "/api/v1/organizations/org-1/projects/proj-1/addons/postgres" for i, got := range requests { - wantMethod := http.MethodPost - if i%2 == 1 { - wantMethod = http.MethodGet - } - if got.method != wantMethod || got.path != endpoint { - t.Errorf("request %d = %s %s, want %s %s", i, got.method, got.path, wantMethod, endpoint) + if got.method != http.MethodGet || got.path != endpoint { + t.Errorf("request %d = %s %s, want GET %s", i, got.method, got.path, endpoint) } } - if requests[0].body != requests[2].body { - t.Errorf("create request bodies differ:\nshortcut: %s\nlegacy: %s", requests[0].body, requests[2].body) - } } func TestPostgresCreateWaitSucceedsForHealthyTerminalStatesOnBothPaths(t *testing.T) { @@ -134,8 +102,8 @@ func TestPostgresCreateWaitSucceedsForHealthyTerminalStatesOnBothPaths(t *testin path []string state string }{ - {name: "shortcut ready", path: []string{"postgres"}, state: "Ready"}, - {name: "legacy running", path: []string{"addon", "postgres"}, state: "Running"}, + {name: "ready", path: []string{"create", "postgres-addon"}, state: "Ready"}, + {name: "running", path: []string{"create", "postgres-addon"}, state: "Running"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -147,7 +115,7 @@ func TestPostgresCreateWaitSucceedsForHealthyTerminalStatesOnBothPaths(t *testin defer server.Close() configurePostgresCLI(t, server.URL) - args := append(append([]string{}, tt.path...), "create", "demo", "--wait", "--timeout", "1s", "-o", "json") + args := append(append([]string{}, tt.path...), "demo", "--wait", "--timeout", "1s", "-o", "json") stdout, stderr, code := runPostgresCLI(args) if code != 0 { t.Fatalf("exit = %d, stderr = %s", code, stderr) @@ -179,7 +147,7 @@ func TestPostgresCreateWaitFailsForTerminalFailureStates(t *testing.T) { defer server.Close() configurePostgresCLI(t, server.URL) - stdout, stderr, code := runPostgresCLI([]string{"postgres", "create", "demo", "--wait", "--timeout", "1s", "-o", "json"}) + stdout, stderr, code := runPostgresCLI([]string{"create", "postgres-addon", "demo", "--wait", "--timeout", "1s", "-o", "json"}) if code == 0 { t.Fatal("exit = 0, want failure") } @@ -201,7 +169,7 @@ func TestPostgresCreateWaitTimeoutIsBounded(t *testing.T) { configurePostgresCLI(t, server.URL) started := time.Now() - stdout, stderr, code := runPostgresCLI([]string{"postgres", "create", "demo", "--wait", "--timeout", "20ms", "-o", "json"}) + stdout, stderr, code := runPostgresCLI([]string{"create", "postgres-addon", "demo", "--wait", "--timeout", "20ms", "-o", "json"}) if code == 0 { t.Fatal("exit = 0, want timeout") } diff --git a/cmd/stackdome/apply.go b/cmd/stackdome/apply.go new file mode 100644 index 0000000..67497af --- /dev/null +++ b/cmd/stackdome/apply.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + "github.com/Stackdome/stackdome-cli/internal/stackfile" + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" + "github.com/spf13/cobra" +) + +type applyOptions struct { + File string + Name string +} + +func newApplyCmd() *cobra.Command { + var opts applyOptions + + cmd := &cobra.Command{ + Use: "apply", + Short: "Save a stack definition without releasing it", + Long: `Create or update the saved stack definition from a Stackfile or stack JSON. + +Apply does not create a release or change the running workload. Use +` + "`stackdome create release`" + ` to release the saved definition, or use +` + "`stackdome deploy`" + ` to apply and release in one command.`, + Example: " stackdome apply -f stackfile.yaml\n stackdome apply -f stack.json --name demo -o json", + Args: cobra.NoArgs, + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, _ []string) error { + result, err := applyStackDefinition(ctx, cmd, opts) + if err != nil { + return err + } + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(result) + } + fmt.Fprintf(cmd.ErrOrStderr(), "Stack %q saved. No release was created.\n", result.Name) + fmt.Fprintf(cmd.ErrOrStderr(), "Release it with: stackdome create release --stack %s\n", result.Name) + return nil + })), + } + + cmd.Flags().StringVarP(&opts.File, "file", "f", "stackfile.yaml", "Path to stackfile or stack JSON") + cmd.Flags().StringVar(&opts.Name, "name", "", "Override stack name") + return cmd +} + +func applyStackDefinition(ctx *cmdutil.CommandContext, cmd *cobra.Command, opts applyOptions) (*openapi.Stack, error) { + stack, err := loadStack(opts.File, opts.Name) + if err != nil { + return nil, err + } + if err := stackfile.ResolveStack(cmd.Context(), stack, &apiResolver{c: ctx.Client}); err != nil { + return nil, err + } + result, err := ctx.Client.ApplyStack(cmd.Context(), *stack) + if err != nil { + return nil, err + } + if err := ctx.Config.SetCurrentStack(result.GetId()); err != nil { + return nil, err + } + return result, nil +} diff --git a/cmd/stackdome/apply_test.go b/cmd/stackdome/apply_test.go new file mode 100644 index 0000000..e9b6385 --- /dev/null +++ b/cmd/stackdome/apply_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/Stackdome/stackdome-cli/internal/config" +) + +func TestApplySavesStackWithoutCreatingRelease(t *testing.T) { + var calls []string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPut && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/apply" { + _, _ = w.Write([]byte(`{"id":"stack-1","name":"basic-stack","spec":{}}`)) + return + } + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer ts.Close() + + configPath := filepath.Join(t.TempDir(), "config.json") + t.Setenv("STACKDOME_CONFIG", configPath) + cfg := &config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "proj-1", + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + stackfilePath, err := filepath.Abs(filepath.Join("..", "..", "internal", "stackfile", "testdata", "basic_image.yaml")) + if err != nil { + t.Fatalf("absolute stackfile path: %v", err) + } + var stdout, stderr bytes.Buffer + code := runWithWriters([]string{"apply", "--file", stackfilePath, "--output", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("apply exit code = %d, want 0; stderr: %s", code, stderr.String()) + } + if len(calls) != 1 || calls[0] != "PUT /api/v1/organizations/org-1/projects/proj-1/stacks/apply" { + t.Fatalf("requests = %v, want one stack apply and no release", calls) + } + var got struct { + ID string `json:"id"` + Name string `json:"name"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("stdout is not a stack JSON object: %v\nstdout: %s", err, stdout.String()) + } + if got.ID != "stack-1" || got.Name != "basic-stack" { + t.Errorf("apply result = %#v", got) + } + + reloaded, err := config.Load() + if err != nil { + t.Fatalf("reload config: %v", err) + } + if reloaded.CurrentStack != "stack-1" { + t.Errorf("current stack = %q, want stack-1", reloaded.CurrentStack) + } +} + +func TestCreateReleaseUsesSavedStackWithoutApplying(t *testing.T) { + var calls []string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"demo","spec":{}}]}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"release-1","stack_id":"stack-1","sequence":1,"state":"Pending"}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + configPath := filepath.Join(t.TempDir(), "config.json") + t.Setenv("STACKDOME_CONFIG", configPath) + cfg := &config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "proj-1", + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + var stdout, stderr bytes.Buffer + code := runWithWriters([]string{"create", "release", "--stack", "demo", "--output", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("create release exit code = %d, want 0; stderr: %s", code, stderr.String()) + } + wantCalls := []string{ + "GET /api/v1/organizations/org-1/projects/proj-1/stacks", + "POST /api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases", + } + if len(calls) != len(wantCalls) { + t.Fatalf("requests = %v, want %v", calls, wantCalls) + } + for i := range wantCalls { + if calls[i] != wantCalls[i] { + t.Errorf("request %d = %q, want %q", i, calls[i], wantCalls[i]) + } + } + var got struct { + Release struct { + ID string `json:"id"` + } `json:"release"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("stdout is not release JSON: %v\nstdout: %s", err, stdout.String()) + } + if got.Release.ID != "release-1" { + t.Errorf("release ID = %q, want release-1", got.Release.ID) + } +} diff --git a/cmd/stackdome/build_test.go b/cmd/stackdome/build_test.go index 2d589b9..9d49765 100644 --- a/cmd/stackdome/build_test.go +++ b/cmd/stackdome/build_test.go @@ -527,7 +527,7 @@ func TestBuildLogsUnavailableForPendingBuildDoesNotClaimPruning(t *testing.T) { // runtime logs; prose from the callback would corrupt the document. func TestBuildLogsJSONServerErrorIsSingleRootDocument(t *testing.T) { if os.Getenv("STACKDOME_TEST_BUILD_LOG_ERROR_HELPER") == "1" { - os.Exit(runWithWriters([]string{"build", "logs", "build-1", "--stack", "app", "-o", "json"}, os.Stdout, os.Stderr)) + os.Exit(runWithWriters([]string{"logs", "build", "build-1", "--stack", "app", "-o", "json"}, os.Stdout, os.Stderr)) } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/stackdome/command_tree.go b/cmd/stackdome/command_tree.go new file mode 100644 index 0000000..ff2493f --- /dev/null +++ b/cmd/stackdome/command_tree.go @@ -0,0 +1,223 @@ +package main + +import ( + "fmt" + + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/spf13/cobra" +) + +type commandDocs struct { + Use string + Short string + Long string + Example string +} + +func documentCommand(cmd *cobra.Command, docs commandDocs) *cobra.Command { + cmd.Use = docs.Use + cmd.Short = docs.Short + cmd.Long = docs.Long + cmd.Example = docs.Example + cmd.Aliases = nil + return cmd +} + +func newVerbCommand(use, short, long, example string) *cobra.Command { + return &cobra.Command{ + Use: use, + Short: short, + Long: long, + Example: example, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return nil + } + if correction := nounCorrection(cmd.Name(), args[0]); correction != "" { + return clierrors.ValidationError(fmt.Sprintf("unknown resource %q for %q; use `%s`", args[0], cmd.Name(), correction)) + } + return clierrors.ValidationError(fmt.Sprintf("unknown resource %q for %q; run `stackdome %s --help` to list supported resources", args[0], cmd.Name(), cmd.Name())) + }, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } +} + +func nounCorrection(verb, noun string) string { + corrections := map[string]map[string]string{ + "list": { + "stack": "stackdome list stacks", "build": "stackdome list builds", + "release": "stackdome list releases", "secret": "stackdome list secrets", + "volume": "stackdome list volumes", "postgres-addon": "stackdome list postgres-addons", + "token": "stackdome list tokens", "token-scope": "stackdome list token-scopes", + }, + "describe": { + "stacks": "stackdome describe stack ", "builds": "stackdome describe build ", + "releases": "stackdome describe release ", "secrets": "stackdome describe secret ", + "postgres-addons": "stackdome describe postgres-addon ", + }, + } + return corrections[verb][noun] +} + +func collectionDocs(verb, resource, description, scopeExample string) commandDocs { + equivalent := "list" + if verb == "list" { + equivalent = "get" + } + return commandDocs{ + Use: resource, + Short: description, + Long: fmt.Sprintf("%s\n\nThis command is equivalent to `stackdome %s %s`.", + description+".", equivalent, resource), + Example: fmt.Sprintf(" stackdome %s %s\n stackdome %s %s %s", + verb, resource, verb, resource, scopeExample), + } +} + +func detailDocs(verb, resource, argument, description, example string) commandDocs { + equivalent := "describe" + if verb == "describe" { + equivalent = "get" + } + return commandDocs{ + Use: fmt.Sprintf("%s <%s>", resource, argument), + Short: description, + Long: fmt.Sprintf("%s.\n\nThis command is equivalent to `stackdome %s %s <%s>`.", + description, equivalent, resource, argument), + Example: fmt.Sprintf(" stackdome %s %s %s", verb, resource, example), + } +} + +func operationDocs(use, short, long, example string) commandDocs { + return commandDocs{Use: use, Short: short, Long: long, Example: " " + example} +} + +func newCommandTree() []*cobra.Command { + get := newVerbCommand( + "get", + "Get Stackdome resources", + "Get a resource collection or one identified resource. Use plural resource names for collections and singular names with an identifier for details.", + " stackdome get builds\n stackdome get build ", + ) + list := newVerbCommand( + "list", + "List Stackdome resource collections", + "List a Stackdome resource collection. Every list command is equivalent to its `stackdome get ` form.", + " stackdome list stacks\n stackdome list builds --stack demo", + ) + describe := newVerbCommand( + "describe", + "Describe one Stackdome resource", + "Show details for one identified resource. Every describe command is equivalent to its `stackdome get ` form.", + " stackdome describe stack demo\n stackdome describe build ", + ) + + get.AddCommand( + documentCommand(newStackListCmd(), collectionDocs("get", "stacks", "List all stacks", "-o json")), + documentCommand(newStackInfoCmd(), detailDocs("get", "stack", "stack", "Show one stack by name or ID", "demo")), + documentCommand(newBuildListCmd(), collectionDocs("get", "builds", "List builds for the selected or specified stack", "--stack demo -o json")), + documentCommand(newBuildInfoCmd(), detailDocs("get", "build", "build-id", "Show one build by ID or ID prefix", "")), + documentCommand(newReleaseListCmd(), collectionDocs("get", "releases", "List releases for the selected or specified stack", "--stack demo -o json")), + documentCommand(newReleaseInfoCmd(), detailDocs("get", "release", "release-id", "Show one release by ID or ID prefix", "")), + documentCommand(newReleaseEventsCmd(), operationDocs("release-events ", "List or follow release events", "List events for one release. Pass --follow to stream new events until the stream ends. This command is equivalent to `stackdome list release-events `.", "stackdome get release-events --follow")), + documentCommand(newSecretListCmd(), collectionDocs("get", "secrets", "List project secrets without revealing values", "-o json")), + documentCommand(newSecretInfoCmd(), detailDocs("get", "secret", "name", "Show secret metadata without revealing values", "api-key")), + documentCommand(newVolumeListCmd(), collectionDocs("get", "volumes", "List volumes for the selected or specified stack", "--stack demo")), + documentCommand(newPostgresListCmd(), collectionDocs("get", "postgres-addons", "List PostgreSQL addons", "-o json")), + documentCommand(newPostgresInfoCmd(), detailDocs("get", "postgres-addon", "name", "Show one PostgreSQL addon", "database")), + documentCommand(newPostgresBackupsCmd(), operationDocs("postgres-backups ", "List PostgreSQL backups", "List backups belonging to one PostgreSQL addon. This command is equivalent to `stackdome list postgres-backups `.", "stackdome get postgres-backups database")), + documentCommand(newPostgresCredentialsCmd(), operationDocs("postgres-credentials ", "Get just-in-time PostgreSQL credentials", "Return sensitive, short-lived credentials for one database. Treat all output as secret material.", "stackdome get postgres-credentials database app -o json")), + documentCommand(newTokenListCmd(), collectionDocs("get", "tokens", "List API tokens without revealing token values", "-o json")), + documentCommand(newTokenScopesCmd(), collectionDocs("get", "token-scopes", "List valid API token scopes", "-o json")), + documentCommand(newConfigViewCmd(), operationDocs("config", "Show effective CLI configuration", "Show the active server, authentication method, project, and stack with stored secrets redacted.", "stackdome get config -o yaml")), + documentCommand(newStackfileSchemaCmd(), operationDocs("stackfile-schema", "Print the canonical Stackfile schema", "Print the embedded canonical Stackfile JSON Schema without contacting the server.", "stackdome get stackfile-schema -o json")), + ) + + list.AddCommand( + documentCommand(newStackListCmd(), collectionDocs("list", "stacks", "List all stacks", "-o json")), + documentCommand(newBuildListCmd(), collectionDocs("list", "builds", "List builds for the selected or specified stack", "--stack demo -o json")), + documentCommand(newReleaseListCmd(), collectionDocs("list", "releases", "List releases for the selected or specified stack", "--stack demo -o json")), + documentCommand(newReleaseEventsCmd(), operationDocs("release-events ", "List or follow release events", "List events for one release. This command is equivalent to `stackdome get release-events `.", "stackdome list release-events ")), + documentCommand(newSecretListCmd(), collectionDocs("list", "secrets", "List project secrets without revealing values", "-o json")), + documentCommand(newVolumeListCmd(), collectionDocs("list", "volumes", "List volumes for the selected or specified stack", "--stack demo")), + documentCommand(newPostgresListCmd(), collectionDocs("list", "postgres-addons", "List PostgreSQL addons", "-o json")), + documentCommand(newPostgresBackupsCmd(), operationDocs("postgres-backups ", "List PostgreSQL backups", "List backups belonging to one PostgreSQL addon. This command is equivalent to `stackdome get postgres-backups `.", "stackdome list postgres-backups database")), + documentCommand(newTokenListCmd(), collectionDocs("list", "tokens", "List API tokens without revealing token values", "-o json")), + documentCommand(newTokenScopesCmd(), collectionDocs("list", "token-scopes", "List valid API token scopes", "-o json")), + ) + + describe.AddCommand( + documentCommand(newStackInfoCmd(), detailDocs("describe", "stack", "stack", "Show one stack by name or ID", "demo")), + documentCommand(newBuildInfoCmd(), detailDocs("describe", "build", "build-id", "Show one build by ID or ID prefix", "")), + documentCommand(newReleaseInfoCmd(), detailDocs("describe", "release", "release-id", "Show one release by ID or ID prefix", "")), + documentCommand(newSecretInfoCmd(), detailDocs("describe", "secret", "name", "Show secret metadata without revealing values", "api-key")), + documentCommand(newPostgresInfoCmd(), detailDocs("describe", "postgres-addon", "name", "Show one PostgreSQL addon", "database")), + ) + + create := newVerbCommand("create", "Create Stackdome resources", "Create one Stackdome resource. Resource-specific help documents required flags, output, and side effects.", " stackdome create secret api-key --data KEY=value\n stackdome create volume data --size 5Gi") + create.AddCommand( + documentCommand(newReleaseCreateCmd(), operationDocs("release", "Create a release from saved stack state", "Create a release from the saved definition of the selected or specified stack. This command does not apply a Stackfile. Pass --wait to follow the created release and --timeout to bound the wait.", "stackdome create release --stack demo --wait")), + documentCommand(newSecretCreateCmd(), operationDocs("secret ", "Create a secret", "Create a project secret. Secret values are sensitive and are never shown by later get or describe commands.", "stackdome create secret api-key --data KEY=value")), + documentCommand(newVolumeCreateCmd(), operationDocs("volume ", "Create a stack volume", "Create a volume in the selected or specified stack.", "stackdome create volume data --size 5Gi")), + documentCommand(newPostgresCreateCmd(), operationDocs("postgres-addon ", "Create a PostgreSQL addon", "Provision a PostgreSQL addon. Pass --wait to wait for it to become ready.", "stackdome create postgres-addon database --wait")), + documentCommand(newTokenCreateCmd(), operationDocs("token ", "Create an API token", "Create an API token. Its sensitive value is printed once and cannot be retrieved later.", "stackdome create token ci --scope 'stacks:*' -o json")), + ) + + update := newVerbCommand("update", "Update Stackdome resources", "Update one existing Stackdome resource.", " stackdome update secret api-key --data KEY=value") + update.AddCommand(documentCommand(newSecretSetCmd(), operationDocs("secret ", "Update a secret", "Update an existing secret's values. Values remain hidden from later reads.", "stackdome update secret api-key --data KEY=value"))) + + deleteCmd := newVerbCommand("delete", "Delete Stackdome resources", "Permanently delete one Stackdome resource. Destructive commands prompt unless --yes is supplied.", " stackdome delete stack demo --yes\n stackdome delete secret api-key --yes") + deleteCmd.AddCommand( + documentCommand(newStackDeleteCmd(), operationDocs("stack ", "Delete a stack", "Permanently delete one stack. The command prompts unless --yes is supplied.", "stackdome delete stack demo --yes")), + documentCommand(newSecretDeleteCmd(), operationDocs("secret ", "Delete a secret", "Permanently delete one secret. The command prompts unless --yes is supplied.", "stackdome delete secret api-key --yes")), + documentCommand(newVolumeDeleteCmd(), operationDocs("volume ", "Delete a stack volume", "Permanently delete one volume from the selected or specified stack. The command prompts unless --yes is supplied.", "stackdome delete volume data --yes")), + documentCommand(newPostgresDeleteCmd(), operationDocs("postgres-addon ", "Delete a PostgreSQL addon", "Permanently delete one PostgreSQL addon. The command prompts unless --yes is supplied.", "stackdome delete postgres-addon database --yes")), + documentCommand(newTokenDeleteCmd(), operationDocs("token ", "Delete an API token", "Revoke one API token. The command prompts unless --yes is supplied.", "stackdome delete token --yes")), + ) + + use := newVerbCommand("use", "Select CLI context", "Select the Stackdome server or default stack used by later commands.", " stackdome use stack demo\n stackdome use context https://api.stackdome.example") + use.AddCommand( + documentCommand(newStackUseCmd(), operationDocs("stack ", "Select the default stack", "Resolve a stack by name or ID and persist it as the default for stack-scoped commands.", "stackdome use stack demo")), + documentCommand(newConfigSetContextCmd(), operationDocs("context ", "Select the Stackdome server", "Persist a different Stackdome server URL. Authenticate again after switching servers.", "stackdome use context https://api.stackdome.example")), + ) + + cancel := newVerbCommand("cancel", "Cancel a pending operation", "Cancel a pending Stackdome operation.", " stackdome cancel release ") + cancel.AddCommand(documentCommand(newReleaseCancelCmd(), operationDocs("release ", "Cancel a pending release", "Resolve and cancel one pending release in the selected or specified stack.", "stackdome cancel release "))) + + rollback := newVerbCommand("rollback", "Roll back a resource", "Create new desired state from historical Stackdome state.", " stackdome rollback release --wait") + rollback.AddCommand(documentCommand(newReleaseRollbackCmd(), operationDocs("release ", "Create a release from historical state", "Create a new release from a historical release. Pass --wait to observe it to a terminal state.", "stackdome rollback release --wait"))) + + backup := newVerbCommand("backup", "Back up a resource", "Trigger an immediate backup of a supported Stackdome resource.", " stackdome backup postgres-addon database") + backup.AddCommand(documentCommand(newPostgresBackupCmd(), operationDocs("postgres-addon ", "Back up a PostgreSQL addon", "Trigger an immediate backup of one PostgreSQL addon.", "stackdome backup postgres-addon database --description manual"))) + + export := newVerbCommand("export", "Export Stackdome resources", "Export a Stackdome resource into a local, portable representation.", " stackdome export stackfile demo") + export.AddCommand(documentCommand(newStackfileExportCmd(), operationDocs("stackfile ", "Export a canonical Stackfile", "Export a saved stack as canonical Stackfile YAML or JSON.", "stackdome export stackfile demo --output-file stackfile.yaml"))) + + return []*cobra.Command{get, list, describe, create, update, deleteCmd, use, cancel, rollback, backup, export} +} + +func configureRootHelpGroups(root *cobra.Command) { + root.AddGroup( + &cobra.Group{ID: "read", Title: "Read Resources:"}, + &cobra.Group{ID: "change", Title: "Change Resources:"}, + &cobra.Group{ID: "deploy", Title: "Deploy and Release:"}, + &cobra.Group{ID: "observe", Title: "Observe and Operate:"}, + &cobra.Group{ID: "auth", Title: "Authentication and Context:"}, + &cobra.Group{ID: "tooling", Title: "Local Tooling:"}, + ) + + groups := map[string]string{ + "get": "read", "list": "read", "describe": "read", + "create": "change", "update": "change", "delete": "change", + "apply": "deploy", "deploy": "deploy", "cancel": "deploy", "rollback": "deploy", + "status": "observe", "logs": "observe", "restart": "observe", "open": "observe", "backup": "observe", + "login": "auth", "logout": "auth", "signup": "auth", "whoami": "auth", "use": "auth", + "init": "tooling", "validate": "tooling", "export": "tooling", "doctor": "tooling", + "api": "tooling", "completion": "tooling", "version": "tooling", + } + for _, cmd := range root.Commands() { + cmd.GroupID = groups[cmd.Name()] + } +} diff --git a/cmd/stackdome/command_tree_test.go b/cmd/stackdome/command_tree_test.go new file mode 100644 index 0000000..913abaf --- /dev/null +++ b/cmd/stackdome/command_tree_test.go @@ -0,0 +1,213 @@ +package main + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestVerbFirstCommandPaths(t *testing.T) { + paths := [][]string{ + {"get", "stacks"}, + {"list", "stacks"}, + {"get", "stack", "demo"}, + {"describe", "stack", "demo"}, + {"get", "builds"}, + {"list", "builds"}, + {"get", "build", "build-1"}, + {"describe", "build", "build-1"}, + {"get", "releases"}, + {"list", "releases"}, + {"get", "release", "release-1"}, + {"describe", "release", "release-1"}, + {"get", "release-events", "release-1"}, + {"list", "release-events", "release-1"}, + {"get", "secrets"}, + {"list", "secrets"}, + {"get", "secret", "api-key"}, + {"describe", "secret", "api-key"}, + {"get", "volumes"}, + {"list", "volumes"}, + {"get", "postgres-addons"}, + {"list", "postgres-addons"}, + {"get", "postgres-addon", "database"}, + {"describe", "postgres-addon", "database"}, + {"get", "postgres-backups", "database"}, + {"list", "postgres-backups", "database"}, + {"get", "postgres-credentials", "database", "app"}, + {"get", "tokens"}, + {"list", "tokens"}, + {"get", "token-scopes"}, + {"list", "token-scopes"}, + {"get", "config"}, + {"get", "stackfile-schema"}, + {"create", "release"}, + {"create", "secret", "api-key"}, + {"update", "secret", "api-key"}, + {"delete", "secret", "api-key"}, + {"create", "volume", "data"}, + {"delete", "volume", "data"}, + {"create", "postgres-addon", "database"}, + {"delete", "postgres-addon", "database"}, + {"create", "token", "ci"}, + {"delete", "token", "token-1"}, + {"delete", "stack", "demo"}, + {"cancel", "release", "release-1"}, + {"rollback", "release", "release-1"}, + {"backup", "postgres-addon", "database"}, + {"use", "stack", "demo"}, + {"use", "context", "https://api.stackdome.example"}, + {"export", "stackfile", "demo"}, + } + + root := newRootCmd() + for _, path := range paths { + path := path + t.Run(strings.Join(path, "_"), func(t *testing.T) { + cmd, _, err := root.Find(path) + if err != nil { + t.Fatalf("Find(%q): %v", path, err) + } + if cmd == root || !cmd.Runnable() { + t.Fatalf("Find(%q) resolved to non-runnable %q", path, cmd.CommandPath()) + } + }) + } +} + +func TestWrongNounFormsReturnCorrectiveUsage(t *testing.T) { + tests := []struct { + args []string + want string + }{ + {args: []string{"list", "build"}, want: "stackdome list builds"}, + {args: []string{"describe", "builds"}, want: "stackdome describe build "}, + } + for _, tt := range tests { + var stdout, stderr bytes.Buffer + code := runWithWriters(tt.args, &stdout, &stderr) + if code != 4 { + t.Errorf("%v exit code = %d, want 4; stderr: %s", tt.args, code, stderr.String()) + } + if !strings.Contains(stderr.String(), tt.want) { + t.Errorf("%v stderr omitted %q:\n%s", tt.args, tt.want, stderr.String()) + } + } +} + +func TestExecutableCommandsHaveAgentReadableHelp(t *testing.T) { + var visit func(*cobra.Command) + visit = func(cmd *cobra.Command) { + if cmd.Runnable() { + if strings.TrimSpace(cmd.Use) == "" || strings.TrimSpace(cmd.Short) == "" || + strings.TrimSpace(cmd.Long) == "" || strings.TrimSpace(cmd.Example) == "" { + t.Errorf("%s has incomplete agent help (Use=%t Short=%t Long=%t Example=%t)", + cmd.CommandPath(), strings.TrimSpace(cmd.Use) != "", strings.TrimSpace(cmd.Short) != "", + strings.TrimSpace(cmd.Long) != "", strings.TrimSpace(cmd.Example) != "") + } + } + for _, child := range cmd.Commands() { + visit(child) + } + } + visit(newRootCmd()) +} + +func TestRootCommandsAreGroupedByPurpose(t *testing.T) { + root := newRootCmd() + wantGroups := map[string]bool{ + "read": false, "change": false, "deploy": false, + "observe": false, "auth": false, "tooling": false, + } + for _, group := range root.Groups() { + if _, ok := wantGroups[group.ID]; ok { + wantGroups[group.ID] = true + } + } + for group, found := range wantGroups { + if !found { + t.Errorf("root help group %q is not registered", group) + } + } + for _, cmd := range root.Commands() { + if cmd.Name() == "help" { + continue + } + if cmd.GroupID == "" { + t.Errorf("root command %q has no help group", cmd.Name()) + } + } +} + +func TestSafetyCriticalHelpDocumentsSideEffects(t *testing.T) { + root := newRootCmd() + tests := []struct { + path []string + want []string + }{ + {path: []string{"apply"}, want: []string{"does not create a release", "create release", "deploy"}}, + {path: []string{"create", "release"}, want: []string{"does not apply", "--wait", "--timeout"}}, + {path: []string{"deploy"}, want: []string{"saves the stack definition", "creates a release", "apply"}}, + {path: []string{"create", "token"}, want: []string{"sensitive", "once"}}, + {path: []string{"get", "postgres-credentials"}, want: []string{"sensitive", "secret"}}, + {path: []string{"delete", "stack"}, want: []string{"Permanently", "--yes"}}, + {path: []string{"list", "builds"}, want: []string{"equivalent", "stackdome get builds"}}, + {path: []string{"describe", "build"}, want: []string{"equivalent", "stackdome get build"}}, + } + for _, tt := range tests { + cmd, _, err := root.Find(tt.path) + if err != nil { + t.Fatalf("find %v: %v", tt.path, err) + } + text := cmd.Long + "\n" + cmd.Example + for _, want := range tt.want { + if !strings.Contains(strings.ToLower(text), strings.ToLower(want)) { + t.Errorf("%s help omitted %q:\n%s", cmd.CommandPath(), want, text) + } + } + } +} + +func TestLifecycleCommandsRejectUnexpectedPositionalArguments(t *testing.T) { + for _, args := range [][]string{ + {"apply", "unexpected"}, + {"deploy", "unexpected"}, + {"create", "release", "unexpected"}, + } { + var stdout, stderr bytes.Buffer + if code := runWithWriters(args, &stdout, &stderr); code != 4 { + t.Errorf("%v exit code = %d, want usage exit 4; stderr: %s", args, code, stderr.String()) + } + } +} + +func TestLogsBuildResolvesToBuildLogCommand(t *testing.T) { + root := newRootCmd() + cmd, _, err := root.Find([]string{"logs", "build", "build-1"}) + if err != nil { + t.Fatalf("find logs build: %v", err) + } + if got := cmd.CommandPath(); got != "stackdome logs build" { + t.Fatalf("command path = %q, want stackdome logs build", got) + } +} + +func TestLegacyResourceFirstPathsAreRemoved(t *testing.T) { + legacyRoots := []string{ + "stack", "build", "release", "secret", "volume", "addon", "postgres", + "token", "config", "stackfile", "destroy", + } + root := newRootCmd() + for _, name := range legacyRoots { + cmd, _, err := root.Find([]string{name}) + if err == nil && cmd != root && cmd.Name() == name { + t.Errorf("legacy root %q still resolves to %s", name, cmd.CommandPath()) + } + } + var stdout, stderr bytes.Buffer + if code := runWithWriters([]string{"stack", "list"}, &stdout, &stderr); code != 4 { + t.Errorf("removed command exit code = %d, want usage exit 4; stderr: %s", code, stderr.String()) + } +} diff --git a/cmd/stackdome/config.go b/cmd/stackdome/config.go index 182c092..2a9f63c 100644 --- a/cmd/stackdome/config.go +++ b/cmd/stackdome/config.go @@ -55,7 +55,7 @@ func newConfigSetStackCmd() *cobra.Command { Short: "Set the current stack context (name or ID)", Args: cobra.ExactArgs(1), RunE: cmdutil.WithContext(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { - return selectStackContext(ctx, cmd, args[0], "config set-stack") + return selectStackContext(ctx, cmd, args[0], "use stack") }), } } @@ -74,7 +74,7 @@ func newConfigSetContextCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: cmdutil.WithContext(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { if ctx.Config.ContextFromEnv() { - return clierrors.ValidationError("config set-context cannot override STACKDOME_URL or STACKDOME_TOKEN; unset them or change those environment variables") + return clierrors.ValidationError("use context cannot override STACKDOME_URL or STACKDOME_TOKEN; unset them or change those environment variables") } newURL := args[0] if newURL == "" { diff --git a/cmd/stackdome/config_output_test.go b/cmd/stackdome/config_output_test.go index 7378ef7..e6cf172 100644 --- a/cmd/stackdome/config_output_test.go +++ b/cmd/stackdome/config_output_test.go @@ -13,7 +13,7 @@ import ( func TestConfigSetContextJSONPrintsResult(t *testing.T) { t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) var stdout, stderr bytes.Buffer - code := runWithWriters([]string{"config", "set-context", "https://example.stackdome.test", "-o", "json"}, &stdout, &stderr) + code := runWithWriters([]string{"use", "context", "https://example.stackdome.test", "-o", "json"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String()) } @@ -43,7 +43,7 @@ func TestConfigSetStackRejectsEphemeralEnvTokenContext(t *testing.T) { t.Setenv("STACKDOME_PROJECT", "default") var stdout, stderr bytes.Buffer - code := runWithWriters([]string{"config", "set-stack", "app", "-o", "json"}, &stdout, &stderr) + code := runWithWriters([]string{"use", "stack", "app", "-o", "json"}, &stdout, &stderr) if code == 0 { t.Fatal("set-stack succeeded even though an env-token process cannot persist the selection") } @@ -64,7 +64,7 @@ func TestConfigSetContextRejectsEnvironmentOverrides(t *testing.T) { t.Setenv("STACKDOME_TOKEN", "sdm_ephemeral") var stdout, stderr bytes.Buffer - code := runWithWriters([]string{"config", "set-context", "https://new.example", "-o", "json"}, &stdout, &stderr) + code := runWithWriters([]string{"use", "context", "https://new.example", "-o", "json"}, &stdout, &stderr) if code == 0 { t.Fatal("set-context succeeded even though environment overrides would keep the old context active") } diff --git a/cmd/stackdome/deploy.go b/cmd/stackdome/deploy.go index 29a0270..573b56b 100644 --- a/cmd/stackdome/deploy.go +++ b/cmd/stackdome/deploy.go @@ -32,37 +32,25 @@ func newDeployCmd() *cobra.Command { Long: `Deploy a stack from a stackfile or JSON. With -o json|yaml stdout carries {"stack": ..., "release": ...} — the release id -is the one to follow with ` + "`stackdome release events -f`" + `. With --wait -the release object is the final one.`, +is the one to follow with ` + "`stackdome get release-events -f`" + `. Deploy +first saves the stack definition, then creates a release. Use ` + "`stackdome apply`" + ` +to save without releasing. With --wait the release object is the final one.`, + Example: " stackdome deploy -f stackfile.yaml\n stackdome deploy -f stackfile.yaml --wait --timeout 15m\n stackdome deploy -f stackfile.yaml -o json", + Args: cobra.NoArgs, RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { - stack, err := loadStack(flagFile, flagName) - if err != nil { - return err - } - - // Names in `secrets:`/`addons:` become IDs before the document is - // sent — the server only speaks IDs. - if err := stackfile.ResolveStack(cmd.Context(), stack, &apiResolver{c: ctx.Client}); err != nil { - return err - } - if ctx.Formatter.IsTable() { - fmt.Fprintf(os.Stderr, "Applying stack %q...\n", stack.Name) + fmt.Fprintln(os.Stderr, "Applying stack definition...") } - result, err := ctx.Client.ApplyStack(cmd.Context(), *stack) + result, err := applyStackDefinition(ctx, cmd, applyOptions{File: flagFile, Name: flagName}) if err != nil { return err } - if err := ctx.Config.SetCurrentStack(*result.Id); err != nil { - return err - } - // Apply only stores the document; the release is what rolls it // out. Its id is the one --wait follows — never a pre-existing one. - release, err := ctx.Client.CreateRelease(cmd.Context(), *result.Id) + release, err := submitRelease(ctx, cmd, result.GetId()) if err != nil { - return err + return clierrors.Wrap(err, "Stack was saved, but the release was not created") } if !flagWait { @@ -70,7 +58,7 @@ the release object is the final one.`, return ctx.Formatter.PrintStructured(deployResult{Stack: result, Release: release}) } fmt.Fprintf(os.Stderr, "\nRelease #%d for stack %q submitted. Track progress with:\n", release.GetSequence(), result.Name) - fmt.Fprintf(os.Stderr, " stackdome release events %s -f\n", release.GetId()) + fmt.Fprintf(os.Stderr, " stackdome get release-events %s -f\n", release.GetId()) fmt.Fprintf(os.Stderr, " stackdome status --watch # live updates\n") fmt.Fprintf(os.Stderr, " stackdome logs # stream logs\n") return nil @@ -113,7 +101,7 @@ func (r *apiResolver) ResolveSecretByName(ctx context.Context, name string) (str return "", err } if secret == nil || secret.Id == nil { - return "", clierrors.Newf("Secret %q not found. Run `stackdome secret list` to see available secrets.", name) + return "", clierrors.Newf("Secret %q not found. Run `stackdome get secrets` to see available secrets.", name) } return *secret.Id, nil } diff --git a/cmd/stackdome/logs.go b/cmd/stackdome/logs.go index fdf205e..7b08ff2 100644 --- a/cmd/stackdome/logs.go +++ b/cmd/stackdome/logs.go @@ -18,10 +18,11 @@ type logEvent struct { func newLogsCmd() *cobra.Command { var ( - flagFollow bool - flagTail int32 - flagSince string - flagStack string + flagFollow bool + flagTail int32 + flagSince string + flagStack string + flagResource string ) cmd := &cobra.Command{ @@ -29,6 +30,9 @@ func newLogsCmd() *cobra.Command { Short: "Stream logs from a stack or resource", Args: cobra.MaximumNArgs(1), RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + if flagResource != "" && len(args) > 0 { + return clierrors.ValidationError("pass a runtime resource either as the positional argument or with --resource, not both") + } if err := output.ValidateStreamingFormat(ctx.Formatter.Format); err != nil { return err } @@ -37,7 +41,7 @@ func newLogsCmd() *cobra.Command { return err } - resourceName := "" + resourceName := flagResource if len(args) > 0 { resourceName = args[0] } @@ -77,6 +81,13 @@ func newLogsCmd() *cobra.Command { cmd.Flags().Int32Var(&flagTail, "tail", 100, "Number of lines to show") cmd.Flags().StringVar(&flagSince, "since", "", "Show logs since duration (e.g. 5m, 1h)") cmd.Flags().StringVarP(&flagStack, "stack", "s", "", "Stack name (overrides current context)") + cmd.Flags().StringVarP(&flagResource, "resource", "r", "", "Filter to one runtime resource (use for a resource named build)") + cmd.AddCommand(documentCommand(newBuildLogsCmd(), operationDocs( + "build ", + "Stream logs for one build", + "Read logs for one build in the selected or specified stack. Pass --follow to continue streaming output.", + "stackdome logs build --follow", + ))) return cmd } diff --git a/cmd/stackdome/logs_test.go b/cmd/stackdome/logs_test.go index 0a8fd61..905c322 100644 --- a/cmd/stackdome/logs_test.go +++ b/cmd/stackdome/logs_test.go @@ -149,6 +149,34 @@ func TestLogsTableWritesRawData(t *testing.T) { } } +func TestLogsResourceFlagSelectsRuntimeResourceNamedBuild(t *testing.T) { + const stackID = "b02262ac-8e6e-45cd-b18e-acb5d3f97ce4" + var requestedPath string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestedPath = r.URL.Path + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: end\ndata: {}\n\n")) + })) + defer ts.Close() + + ctx := cmdutil.NewCommandContext(&config.Config{ + ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", + ProjectName: "proj-1", CurrentStack: stackID, + }, output.FormatJSON, slog.LevelError) + cmd := newLogsCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--resource", "build"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("logs --resource build: %v", err) + } + want := "/api/v1/organizations/org-1/projects/proj-1/stacks/" + stackID + "/resources/build/logs" + if requestedPath != want { + t.Errorf("request path = %q, want %q", requestedPath, want) + } +} + // A server-side SSE error must reach the root error boundary without first // leaking a prose line that would make JSON stderr undecodable. func TestLogsJSONServerErrorIsSingleRootDocument(t *testing.T) { diff --git a/cmd/stackdome/release.go b/cmd/stackdome/release.go index 4484cf2..cc345d1 100644 --- a/cmd/stackdome/release.go +++ b/cmd/stackdome/release.go @@ -150,6 +150,69 @@ type rollbackResult struct { LiveStatus *openapi.ReleaseLiveStatus `json:"live_status,omitempty" yaml:"live_status,omitempty"` } +func newReleaseCreateCmd() *cobra.Command { + var ( + flagStack string + flagWait bool + flagTimeout time.Duration + ) + + cmd := &cobra.Command{ + Use: "release", + Short: "Create a release from saved stack state", + Long: `Create a release from the currently saved definition of the selected or +specified stack. This command does not apply a Stackfile or change saved state. +Pass --wait to follow the created release and verify its converged live status.`, + Example: " stackdome create release\n stackdome create release --stack demo --wait\n stackdome create release --stack demo -o json", + Args: cobra.NoArgs, + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, _ []string) error { + stackID, err := resolveStackID(ctx, cmd, flagStack) + if err != nil { + return err + } + release, err := submitRelease(ctx, cmd, stackID) + if err != nil { + return err + } + if !flagWait { + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(rollbackResult{Release: release}) + } + fmt.Fprintf(cmd.ErrOrStderr(), "Release #%d submitted. Track progress with:\n", release.GetSequence()) + fmt.Fprintf(cmd.ErrOrStderr(), " stackdome get release-events %s --follow\n", release.GetId()) + return nil + } + + waitCtx, cancel := waitContext(cmd.Context(), flagTimeout) + defer cancel() + waitCmd := *cmd + waitCmd.SetContext(waitCtx) + final, waitErr := followRelease(ctx, &waitCmd, stackID, release.GetId()) + if err := waitCommandError(cmd.Context(), waitCtx, waitErr); err != nil { + return err + } + stack, live, err := fetchDeployObservation(ctx, &waitCmd, stackID, release.GetId(), final) + if err := waitCommandError(cmd.Context(), waitCtx, err); err != nil { + return err + } + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(rollbackResult{Release: final, LiveStatus: live}) + } + output.RenderStackStatus(os.Stdout, stack, live, false) + return nil + })), + } + + cmd.Flags().StringVarP(&flagStack, "stack", "s", "", "Stack name (overrides current context)") + cmd.Flags().BoolVarP(&flagWait, "wait", "w", false, "Wait for the release to finish") + cmd.Flags().DurationVar(&flagTimeout, "timeout", defaultWaitTimeout, "Maximum time to wait for the release") + return cmd +} + +func submitRelease(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID string) (*openapi.StackRelease, error) { + return ctx.Client.CreateRelease(cmd.Context(), stackID) +} + func newReleaseRollbackCmd() *cobra.Command { var ( flagStack string @@ -181,7 +244,7 @@ func newReleaseRollbackCmd() *cobra.Command { return ctx.Formatter.PrintStructured(rollbackResult{Release: release}) } fmt.Fprintf(os.Stderr, "Rollback release #%d submitted. Track progress with:\n", release.GetSequence()) - fmt.Fprintf(os.Stderr, " stackdome release events %s -f\n", release.GetId()) + fmt.Fprintf(os.Stderr, " stackdome get release-events %s -f\n", release.GetId()) return nil } @@ -230,7 +293,7 @@ func newReleaseEventsCmd() *cobra.Command { Args: cobra.ExactArgs(1), PreRunE: cmdutil.WithContext(func(ctx *cmdutil.CommandContext, _ *cobra.Command, _ []string) error { if flagFollow && ctx.Formatter.Format == output.FormatYAML { - return clierrors.ValidationError("release events --follow does not support YAML output; use table or JSON") + return clierrors.ValidationError("get release-events --follow does not support YAML output; use table or JSON") } return nil }), diff --git a/cmd/stackdome/release_test.go b/cmd/stackdome/release_test.go index 13990f3..584019b 100644 --- a/cmd/stackdome/release_test.go +++ b/cmd/stackdome/release_test.go @@ -657,6 +657,31 @@ func rollbackCommandContext(serverURL string) (*cmdutil.CommandContext, *bytes.B return ctx, stdout } +func TestCreateReleaseWaitObservesTheCreatedRelease(t *testing.T) { + ts := rollbackObservationServer(t, + `{"id":"`+rollbackTestStackID+`","name":"demo","spec":{},"converged_release":{"id":"release-rollback"}}`, + `{"id":"release-rollback","stack_id":"`+rollbackTestStackID+`","state":"Released","live_status":{"health":"ok"}}`, + ) + defer ts.Close() + + ctx, stdout := rollbackCommandContext(ts.URL) + cmd := newReleaseCreateCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--wait"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("create release --wait: %v", err) + } + var got rollbackResult + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("stdout is not JSON: %v\nstdout: %s", err, stdout.String()) + } + if got.Release == nil || got.LiveStatus == nil { + t.Errorf("wait result omitted release or live status: %#v", got) + } +} + func rollbackObservationServer(t *testing.T, stackResponse, liveResponse string) *httptest.Server { t.Helper() var releaseReads int diff --git a/cmd/stackdome/root.go b/cmd/stackdome/root.go index ebc74dc..43e16aa 100644 --- a/cmd/stackdome/root.go +++ b/cmd/stackdome/root.go @@ -73,32 +73,24 @@ Exit codes: rootCmd.PersistentFlags().BoolVar(&flagNoColor, "no-color", false, "Disable colored output") rootCmd.PersistentFlags().StringVarP(&flagOutput, "output", "o", "table", "Output format (table, json, yaml)") - rootCmd.AddCommand(newVersionCmd()) - rootCmd.AddCommand(newLoginCmd()) - rootCmd.AddCommand(newLogoutCmd()) - rootCmd.AddCommand(newSignupCmd()) - rootCmd.AddCommand(newWhoamiCmd()) - rootCmd.AddCommand(newConfigCmd()) + rootCmd.AddCommand(documentCommand(newVersionCmd(), operationDocs("version", "Print the CLI version", "Print build version, commit, date, and Go runtime metadata. Structured output is available with -o json or yaml.", "stackdome version -o json"))) + rootCmd.AddCommand(documentCommand(newLoginCmd(), operationDocs("login", "Authenticate with a Stackdome server", "Authenticate with an API token or email and password, then persist the resulting credentials unless environment variables control the session.", "stackdome login --url https://api.stackdome.example --token "))) + rootCmd.AddCommand(documentCommand(newLogoutCmd(), operationDocs("logout", "Clear stored credentials", "Remove credentials stored for the active Stackdome context. Environment-supplied credentials are not modified.", "stackdome logout"))) + rootCmd.AddCommand(documentCommand(newSignupCmd(), operationDocs("signup", "Create a new Stackdome account", "Create an account and organization, authenticate the new session, and persist its credentials.", "stackdome signup --url https://api.stackdome.example --name 'Ada Lovelace' --email ada@example.com --org example"))) + rootCmd.AddCommand(documentCommand(newWhoamiCmd(), operationDocs("whoami", "Show the active identity and scope", "Show the authenticated user, organization, project, server, and authentication method without revealing credentials.", "stackdome whoami -o json"))) rootCmd.AddCommand(newDeployCmd()) - rootCmd.AddCommand(newStatusCmd()) - rootCmd.AddCommand(newDestroyCmd()) - rootCmd.AddCommand(newValidateCmd()) - rootCmd.AddCommand(newStackCmd()) - rootCmd.AddCommand(newLogsCmd()) - rootCmd.AddCommand(newBuildCmd()) - rootCmd.AddCommand(newReleaseCmd()) - rootCmd.AddCommand(newRestartCmd()) - rootCmd.AddCommand(newOpenCmd()) - rootCmd.AddCommand(newSecretCmd()) - rootCmd.AddCommand(newVolumeCmd()) - rootCmd.AddCommand(newAddonCmd()) - rootCmd.AddCommand(newPostgresCmd()) - rootCmd.AddCommand(newTokenCmd()) - rootCmd.AddCommand(newInitCmd()) - rootCmd.AddCommand(newDoctorCmd()) - rootCmd.AddCommand(newStackfileCmd()) - rootCmd.AddCommand(newAPICmd()) - rootCmd.AddCommand(newCompletionCmd()) + rootCmd.AddCommand(newApplyCmd()) + rootCmd.AddCommand(documentCommand(newStatusCmd(), operationDocs("status [resource]", "Show stack and resource status", "Show live status for the selected or specified stack, optionally filtered to one runtime resource. Pass --watch to refresh continuously.", "stackdome status --watch\n stackdome status web --stack demo"))) + rootCmd.AddCommand(documentCommand(newValidateCmd(), operationDocs("validate", "Validate a Stackfile", "Load and validate a Stackfile locally without authenticating, saving stack state, or creating a release.", "stackdome validate -f stackfile.yaml"))) + rootCmd.AddCommand(documentCommand(newLogsCmd(), operationDocs("logs [resource]", "Read or follow runtime and build logs", "Read logs for the selected stack, optionally filtered to one runtime resource. Use `logs build ` for build logs and --follow to stream.", "stackdome logs --follow\n stackdome logs web --tail 200\n stackdome logs build --follow"))) + rootCmd.AddCommand(documentCommand(newRestartCmd(), operationDocs("restart ", "Restart a stack resource", "Request a restart of one runtime resource in the selected or specified stack.", "stackdome restart web --stack demo"))) + rootCmd.AddCommand(documentCommand(newOpenCmd(), operationDocs("open [resource]", "Open a public resource URL", "Open the first public URL for the selected stack or runtime resource. Structured output prints URLs without launching a browser.", "stackdome open web\n stackdome open --stack demo -o json"))) + rootCmd.AddCommand(documentCommand(newInitCmd(), operationDocs("init", "Scaffold a new Stackfile", "Create stackfile.yaml from an interactive scaffold or convert a supported Compose file. Existing files require --force to overwrite.", "stackdome init\n stackdome init --file compose.yaml"))) + rootCmd.AddCommand(documentCommand(newDoctorCmd(), operationDocs("doctor", "Diagnose CLI connectivity and context", "Check local configuration, server connectivity, authentication, project scope, selected stack, and Stackfile readiness without mutating remote state.", "stackdome doctor -o json"))) + rootCmd.AddCommand(documentCommand(newAPICmd(), operationDocs("api PATH", "Send an authenticated API request", "Send an authenticated request to a Stackdome API path. Mutating methods prompt unless --yes is supplied; request and response bodies use JSON.", "stackdome api /api/v1/users/current\n stackdome api /api/v1/example -X POST --data '{\"name\":\"demo\"}' --yes"))) + rootCmd.AddCommand(documentCommand(newCompletionCmd(), operationDocs("completion [bash|zsh|fish]", "Generate shell completion", "Generate a completion script for bash, zsh, or fish and write it to stdout for installation by the shell.", "stackdome completion zsh > ~/.zfunc/_stackdome"))) + rootCmd.AddCommand(newCommandTree()...) + configureRootHelpGroups(rootCmd) // Usage errors exit 4, as the help text above promises. Cobra reports bad // flags and bad arg counts as plain errors, which would otherwise exit 1. @@ -160,6 +152,9 @@ func runWithContext(ctx context.Context, args []string, stdout, stderr io.Writer rootCmd.SetOut(stdout) rootCmd.SetErr(stderr) if err := rootCmd.ExecuteContext(ctx); err != nil { + if strings.HasPrefix(err.Error(), "unknown command ") { + err = clierrors.ValidationError(err.Error()) + } msg := clierrors.UserMessage(err) exitCode := clierrors.ExitCodeFrom(err) if jsonErrors { diff --git a/cmd/stackdome/root_test.go b/cmd/stackdome/root_test.go index 25f9500..d943b7b 100644 --- a/cmd/stackdome/root_test.go +++ b/cmd/stackdome/root_test.go @@ -18,13 +18,13 @@ import ( // Stackfile discovery unreachable even if their implementation still builds. func TestRootRegistersAgentLaunchCommands(t *testing.T) { root := newRootCmd() - for _, name := range []string{"doctor", "stackfile", "api"} { - command, _, err := root.Find([]string{name}) + for _, path := range [][]string{{"doctor"}, {"get", "stackfile-schema"}, {"api"}} { + command, _, err := root.Find(path) if err != nil { - t.Fatalf("find %s: %v", name, err) + t.Fatalf("find %v: %v", path, err) } - if command == root || command.Name() != name { - t.Errorf("root command %q is not registered", name) + if command == root || !command.Runnable() { + t.Errorf("agent command %v is not registered", path) } } } @@ -101,8 +101,8 @@ func TestRunWithWritersShowsLeafHelpWhenRequiredArgsAreMissing(t *testing.T) { name string args []string }{ - {name: "table", args: []string{"secret", "create"}}, - {name: "yaml", args: []string{"secret", "create", "--output", "yaml"}}, + {name: "table", args: []string{"create", "secret"}}, + {name: "yaml", args: []string{"create", "secret", "--output", "yaml"}}, } for _, tt := range tests { @@ -117,7 +117,7 @@ func TestRunWithWritersShowsLeafHelpWhenRequiredArgsAreMissing(t *testing.T) { if stdout.Len() != 0 { t.Fatalf("stdout = %q, want empty", stdout.String()) } - const usage = "Usage:\n stackdome secret create [flags]" + const usage = "Usage:\n stackdome create secret [flags]" if !strings.Contains(stderr.String(), usage) { t.Errorf("stderr does not contain command help usage %q:\n%s", usage, stderr.String()) } @@ -131,7 +131,7 @@ func TestRunWithWritersShowsLeafHelpWhenRequiredArgsAreMissing(t *testing.T) { func TestRunWithWritersKeepsMissingArgsErrorMachineReadableInJSONMode(t *testing.T) { var stdout, stderr bytes.Buffer - code := runWithWriters([]string{"secret", "create", "--output", "json"}, &stdout, &stderr) + code := runWithWriters([]string{"create", "secret", "--output", "json"}, &stdout, &stderr) if code != 4 { t.Fatalf("exit code = %d, want 4", code) @@ -211,7 +211,7 @@ func TestRunWithContextReleaseEventsFollowCancellationUsesJSONErrorContract(t *t var stdout, stderr bytes.Buffer codeCh := make(chan int, 1) go func() { - codeCh <- runWithContext(parent, []string{"release", "events", "release-follow", "--follow", "--output", "json"}, &stdout, &stderr) + codeCh <- runWithContext(parent, []string{"get", "release-events", "release-follow", "--follow", "--output", "json"}, &stdout, &stderr) }() select { diff --git a/cmd/stackdome/stack.go b/cmd/stackdome/stack.go index ff00b51..7bc3d97 100644 --- a/cmd/stackdome/stack.go +++ b/cmd/stackdome/stack.go @@ -30,7 +30,7 @@ func newStackUseCmd() *cobra.Command { Short: "Select the current stack by name or ID", Args: cobra.ExactArgs(1), RunE: cmdutil.WithContext(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { - return selectStackContext(ctx, cmd, args[0], "stack use") + return selectStackContext(ctx, cmd, args[0], "use stack") }), } } @@ -136,27 +136,28 @@ func newStackDeleteCmd() *cobra.Command { var flagYes bool cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a stack by name", + Use: "delete ", + Short: "Delete a stack by name or ID", Args: cobra.ExactArgs(1), RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { - stack, err := ctx.Client.FindStackByName(cmd.Context(), args[0]) + stackID, err := resolveStackRef(ctx, cmd, args[0]) if err != nil { return err } - if stack == nil { - return clierrors.NotFoundError("Stack", args[0]) + stack, err := ctx.Client.GetStack(cmd.Context(), stackID) + if err != nil { + return err } if _, err := cmdutil.Confirm(ctx.Formatter, fmt.Sprintf("Delete stack %q?", stack.Name), flagYes); err != nil { return err } - if err := ctx.Client.DeleteStack(cmd.Context(), *stack.Id); err != nil { + if err := ctx.Client.DeleteStack(cmd.Context(), stackID); err != nil { return err } - if ctx.Config.CurrentStack == *stack.Id { + if ctx.Config.CurrentStack == stackID { _ = ctx.Config.SetCurrentStack("") } @@ -164,7 +165,7 @@ func newStackDeleteCmd() *cobra.Command { Status: "deletion_initiated", Resource: "stack", Name: stack.Name, - ID: stack.GetId(), + ID: stackID, }, fmt.Sprintf("Stack %q deletion initiated.", stack.Name)) })), } diff --git a/cmd/stackdome/stack_test.go b/cmd/stackdome/stack_test.go index 8117f51..1c71578 100644 --- a/cmd/stackdome/stack_test.go +++ b/cmd/stackdome/stack_test.go @@ -147,7 +147,7 @@ func TestStackUseWithEnvironmentTokenRejectsBeforeScopeDiscovery(t *testing.T) { t.Setenv("STACKDOME_TOKEN", "sdm_ephemeral") var stdout, stderr bytes.Buffer - code := runWithWriters([]string{"stack", "use", "n8n", "-o", "json"}, &stdout, &stderr) + code := runWithWriters([]string{"use", "stack", "n8n", "-o", "json"}, &stdout, &stderr) if code != 4 { t.Fatalf("exit code = %d, want validation exit 4; stderr: %s", code, stderr.String()) } @@ -192,7 +192,7 @@ func TestStackUseRejectsEnvironmentSelectionOverridesWithoutChangingFileContext( t.Setenv(tt.key, tt.value(ts.URL)) var stdout, stderr bytes.Buffer - code := runWithWriters([]string{"stack", "use", "n8n", "-o", "json"}, &stdout, &stderr) + code := runWithWriters([]string{"use", "stack", "n8n", "-o", "json"}, &stdout, &stderr) if code != 4 { t.Fatalf("exit code = %d, want validation exit 4; stderr: %s", code, stderr.String()) } @@ -289,6 +289,8 @@ func TestStackDeleteJSONPrintsStructuredResult(t *testing.T) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/default/stacks": _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}],"total":1}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/default/stacks/stack-1": + _, _ = w.Write([]byte(`{"id":"stack-1","name":"app","spec":{}}`)) case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/organizations/org-1/projects/default/stacks/stack-1": deleted = true w.WriteHeader(http.StatusNoContent) @@ -312,7 +314,7 @@ func TestStackDeleteJSONPrintsStructuredResult(t *testing.T) { cmd := newStackDeleteCmd() cmd.SetContext(context.Background()) cmdutil.SetContext(cmd, ctx) - cmd.SetArgs([]string{"app", "--yes"}) + cmd.SetArgs([]string{"stack-1", "--yes"}) if err := cmd.Execute(); err != nil { t.Fatalf("stack delete: %v", err) diff --git a/cmd/stackdome/stackfile.go b/cmd/stackdome/stackfile.go index 5a7b534..b24c731 100644 --- a/cmd/stackdome/stackfile.go +++ b/cmd/stackdome/stackfile.go @@ -37,7 +37,7 @@ func newStackfileSchemaCmd() *cobra.Command { Short: "Print the canonical Stackfile JSON Schema", RunE: func(cmd *cobra.Command, args []string) error { if format != "json" { - return clierrors.ValidationError("stackfile schema only supports -o json") + return clierrors.ValidationError("get stackfile-schema only supports -o json") } if outputFile != "" { if err := os.WriteFile(outputFile, stackfile.SchemaJSON, 0o644); err != nil { @@ -70,7 +70,7 @@ func newStackfileExportCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { if format != "yaml" && format != "json" { - return clierrors.ValidationError("stackfile export supports -o yaml or -o json") + return clierrors.ValidationError("export stackfile supports -o yaml or -o json") } stackID, err := resolveStackRef(ctx, cmd, args[0]) diff --git a/cmd/stackdome/stackfile_test.go b/cmd/stackdome/stackfile_test.go index faa45ee..27dde6a 100644 --- a/cmd/stackdome/stackfile_test.go +++ b/cmd/stackdome/stackfile_test.go @@ -47,7 +47,7 @@ func TestStackfileSchemaAcceptsOutputFlagWhenRegisteredUnderRoot(t *testing.T) { var stdout bytes.Buffer root.SetOut(&stdout) root.SetErr(&bytes.Buffer{}) - root.SetArgs([]string{"stackfile", "schema", "-o", "json"}) + root.SetArgs([]string{"get", "stackfile-schema", "-o", "json"}) if err := root.Execute(); err != nil { t.Fatalf("root stackfile schema: %v", err) @@ -65,7 +65,7 @@ func TestStackfileSchemaOutputFileWritesExactEmbeddedBytes(t *testing.T) { var stdout bytes.Buffer root.SetOut(&stdout) root.SetErr(&bytes.Buffer{}) - root.SetArgs([]string{"stackfile", "schema", "--output-file", outputPath}) + root.SetArgs([]string{"get", "stackfile-schema", "--output-file", outputPath}) if err := root.Execute(); err != nil { t.Fatalf("root stackfile schema --output-file: %v", err) @@ -419,7 +419,7 @@ func TestStackfileExportRootErrorNamesUnsupportedConstruct(t *testing.T) { } var stdout, stderr bytes.Buffer - code := runWithWriters([]string{"stackfile", "export", "app"}, &stdout, &stderr) + code := runWithWriters([]string{"export", "stackfile", "app"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want failure") @@ -477,7 +477,7 @@ func TestStackfileExportRootErrorDoesNotLeakEnvironmentValues(t *testing.T) { } var stdout, stderr bytes.Buffer - code := runWithWriters([]string{"stackfile", "export", "app", "-o", "json"}, &stdout, &stderr) + code := runWithWriters([]string{"export", "stackfile", "app", "-o", "json"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want failure") diff --git a/cmd/stackdome/status.go b/cmd/stackdome/status.go index 2ce63f0..fe3fa18 100644 --- a/cmd/stackdome/status.go +++ b/cmd/stackdome/status.go @@ -7,6 +7,7 @@ import ( "github.com/Stackdome/stackdome-cli/internal/cmdutil" clierrors "github.com/Stackdome/stackdome-cli/internal/errors" "github.com/Stackdome/stackdome-cli/internal/output" + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" "github.com/spf13/cobra" ) @@ -25,6 +26,7 @@ func newStatusCmd() *cobra.Command { cmd := &cobra.Command{ Use: "status [resource]", Short: "Show stack and resource status", + Args: cobra.MaximumNArgs(1), RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { if flagWatch { if err := output.ValidateStreamingFormat(ctx.Formatter.Format); err != nil { @@ -35,9 +37,13 @@ func newStatusCmd() *cobra.Command { if err != nil { return err } + resourceName := "" + if len(args) == 1 { + resourceName = args[0] + } if flagWatch { - return watchStatus(ctx, cmd, stackID, flagConditions) + return watchStatus(ctx, cmd, stackID, resourceName, flagConditions) } stack, err := ctx.Client.GetStack(cmd.Context(), stackID) @@ -48,6 +54,10 @@ func newStatusCmd() *cobra.Command { if err != nil { return err } + stack, live, err = filterStatusResource(stack, live, resourceName) + if err != nil { + return err + } if !ctx.Formatter.IsTable() { return ctx.Formatter.PrintStructured(statusResult{Stack: stack, LiveStatus: live}) @@ -65,7 +75,42 @@ func newStatusCmd() *cobra.Command { return cmd } -func watchStatus(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID string, showConditions bool) error { +func filterStatusResource(stack *openapi.Stack, live *openapi.ReleaseLiveStatus, resourceName string) (*openapi.Stack, *openapi.ReleaseLiveStatus, error) { + if resourceName == "" { + return stack, live, nil + } + + var selected *openapi.StackResource + for i := range stack.Spec.StackResources { + if stack.Spec.StackResources[i].Name == resourceName { + resource := stack.Spec.StackResources[i] + selected = &resource + break + } + } + if selected == nil { + return nil, nil, clierrors.NotFoundError("Resource", resourceName) + } + + filteredStack := *stack + filteredStack.Spec = stack.Spec + filteredStack.Spec.StackResources = []openapi.StackResource{*selected} + if live == nil { + return &filteredStack, nil, nil + } + + filteredLive := *live + if live.Resources != nil { + resources := make(map[string]openapi.StackResourceStatus, 1) + if status, ok := (*live.Resources)[resourceName]; ok { + resources[resourceName] = status + } + filteredLive.Resources = &resources + } + return &filteredStack, &filteredLive, nil +} + +func watchStatus(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID, resourceName string, showConditions bool) error { tick := time.NewTicker(3 * time.Second) defer tick.Stop() @@ -87,6 +132,10 @@ func watchStatus(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID string } return err } + stack, live, err = filterStatusResource(stack, live, resourceName) + if err != nil { + return err + } // Structured mode emits one object per tick — no redraw, no escape // codes, so `status -w -o json` stays parseable as it streams. diff --git a/cmd/stackdome/status_test.go b/cmd/stackdome/status_test.go index 040e175..a1254e7 100644 --- a/cmd/stackdome/status_test.go +++ b/cmd/stackdome/status_test.go @@ -138,6 +138,59 @@ func TestStatusYAMLIncludesStackAndLiveStatus(t *testing.T) { } } +func TestStatusResourceFiltersStackAndLiveStatus(t *testing.T) { + const stackID = "b02262ac-8e6e-45cd-b18e-acb5d3f97ce4" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/organizations/org-1/projects/proj-1/stacks/" + stackID: + _, _ = w.Write([]byte(`{"id":"` + stackID + `","name":"app","spec":{"stack_resources":[{"name":"web"},{"name":"worker"}]},"converged_release":{"id":"rel-7"}}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/" + stackID + "/releases/rel-7": + _, _ = w.Write([]byte(`{"id":"rel-7","live_status":{"health":"ok","resources":{"web":{},"worker":{}}}}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + ctx := cmdutil.NewCommandContext(&config.Config{ + ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", + ProjectName: "proj-1", CurrentStack: stackID, + }, output.FormatJSON, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + cmd := newStatusCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"web"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("status web: %v", err) + } + + var got struct { + Stack struct { + Spec struct { + Resources []struct { + Name string `json:"name"` + } `json:"stack_resources"` + } `json:"spec"` + } `json:"stack"` + LiveStatus struct { + Resources map[string]json.RawMessage `json:"resources"` + } `json:"live_status"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("status output is not JSON: %v\nstdout: %s", err, stdout.String()) + } + if len(got.Stack.Spec.Resources) != 1 || got.Stack.Spec.Resources[0].Name != "web" { + t.Errorf("stack resources = %#v, want only web", got.Stack.Spec.Resources) + } + if len(got.LiveStatus.Resources) != 1 || got.LiveStatus.Resources["web"] == nil { + t.Errorf("live resources = %#v, want only web", got.LiveStatus.Resources) + } +} + // A watched status is a stream, so JSON mode must use one compact result per // line and surface an interruption as cancellation instead of a false success. func TestWatchStatusJSONWritesNDJSONAndReturnsCancellation(t *testing.T) { @@ -169,7 +222,7 @@ func TestWatchStatusJSONWritesNDJSONAndReturnsCancellation(t *testing.T) { ctx.Formatter.Writer = &stdout cmd := newStatusCmd() cmd.SetContext(commandContext) - err := watchStatus(ctx, cmd, "stack-1", false) + err := watchStatus(ctx, cmd, "stack-1", "", false) if err != clierrors.ErrUserCanceled { t.Fatalf("watchStatus error = %v, want cancellation", err) } diff --git a/cmd/stackdome/token.go b/cmd/stackdome/token.go index 227fa57..71b15b9 100644 --- a/cmd/stackdome/token.go +++ b/cmd/stackdome/token.go @@ -16,7 +16,7 @@ func newTokenCmd() *cobra.Command { cmd := &cobra.Command{ Use: "token", Short: "Manage API tokens", - Long: "Manage personal access tokens for headless and CI access.\n\nUse `stackdome token scopes` to discover valid scopes.", + Long: "Manage personal access tokens for headless and CI access.\n\nUse `stackdome get token-scopes` to discover valid scopes.", } cmd.AddCommand(newTokenCreateCmd()) @@ -37,12 +37,12 @@ func newTokenCreateCmd() *cobra.Command { Use: "create ", Short: "Create an API token", Long: "Create an API token. The token value is shown once and cannot be retrieved again.", - Example: " stackdome token create ci --scope 'stacks:*' --scope secrets:read --expires 720h\n" + - " stackdome token create agent --scope '*:*' -o json", + Example: " stackdome create token ci --scope 'stacks:*' --scope secrets:read --expires 720h\n" + + " stackdome create token agent --scope '*:*' -o json", Args: cobra.ExactArgs(1), RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { if len(flagScopes) == 0 { - return clierrors.ValidationError("At least one --scope is required (see `stackdome token scopes`)") + return clierrors.ValidationError("At least one --scope is required (see `stackdome get token-scopes`)") } expiresAt, err := parseExpiry(flagExpires, time.Now()) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 4949c62..daccf63 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -240,9 +240,9 @@ func (c *Config) RequireStack() (string, error) { } if c.CurrentStack == "" { if c.StackContextFromEnv() { - return "", clierrors.New("No stack selected. Run `stackdome stack list`, then pass `--stack `; environment-controlled contexts do not persist stack selection.") + return "", clierrors.New("No stack selected. Run `stackdome get stacks`, then pass `--stack `; environment-controlled contexts do not persist stack selection.") } - return "", clierrors.New("No stack selected. Run `stackdome stack list`, then `stackdome stack use `; or pass `--stack `.") + return "", clierrors.New("No stack selected. Run `stackdome get stacks`, then `stackdome use stack `; or pass `--stack `.") } return c.CurrentStack, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 846221c..531c348 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -302,7 +302,7 @@ func TestRequireStackErrorExplainsHowToSelectExistingStack(t *testing.T) { t.Fatal("RequireStack error = nil, want missing-stack guidance") } message := err.Error() - for _, want := range []string{"stackdome stack list", "stackdome stack use ", "--stack "} { + for _, want := range []string{"stackdome get stacks", "stackdome use stack ", "--stack "} { if !strings.Contains(message, want) { t.Errorf("RequireStack error = %q, want %q", message, want) } @@ -322,10 +322,10 @@ func TestRequireStackWithEnvironmentTokenDoesNotRecommendPersistentSelection(t * t.Fatal("RequireStack error = nil, want missing-stack guidance") } message := err.Error() - if !strings.Contains(message, "--stack ") || !strings.Contains(message, "stackdome stack list") { + if !strings.Contains(message, "--stack ") || !strings.Contains(message, "stackdome get stacks") { t.Fatalf("RequireStack error = %q, want list and --stack guidance", message) } - if strings.Contains(message, "stack use") { + if strings.Contains(message, "use stack") { t.Fatalf("RequireStack error = %q, must not recommend a selection that an environment-token process cannot persist", message) } } @@ -356,10 +356,10 @@ func TestRequireStackWithEnvironmentSelectionOverrideDoesNotRecommendPersistentS t.Fatal("RequireStack error = nil, want missing-stack guidance") } message := err.Error() - if !strings.Contains(message, "--stack ") || !strings.Contains(message, "stackdome stack list") { + if !strings.Contains(message, "--stack ") || !strings.Contains(message, "stackdome get stacks") { t.Fatalf("RequireStack error = %q, want list and --stack guidance", message) } - if strings.Contains(message, "stack use") { + if strings.Contains(message, "use stack") { t.Fatalf("RequireStack error = %q, must not recommend persistent selection under an environment override", message) } })