diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..31b7608 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,132 @@ +name: Release CLI + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: "1.25.x" + cache: true + + - name: Test + run: go test ./... + + - name: Race test + run: go test -race ./... + + - name: Vet + run: go vet ./... + + build: + name: Build ${{ matrix.goos }}/${{ matrix.goarch }} + needs: test + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: windows + goarch: amd64 + - goos: windows + goarch: arm64 + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: "1.25.x" + cache: true + + - name: Build archive + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + run: | + version="${GITHUB_REF_NAME}" + commit="$(git rev-parse --short HEAD)" + build_date="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + mkdir -p package dist + binary="stackdome" + if [ "${GOOS}" = "windows" ]; then + binary="stackdome.exe" + fi + go build -trimpath \ + -ldflags "-s -w -X main.Version=${version} -X main.GitCommit=${commit} -X main.BuildDate=${build_date}" \ + -o "package/${binary}" ./cmd/stackdome + cp LICENSE package/LICENSE + if [ "${GOOS}" = "windows" ]; then + (cd package && zip -q "../dist/stackdome_${version}_${GOOS}_${GOARCH}.zip" "${binary}" LICENSE) + else + tar -C package -czf "dist/stackdome_${version}_${GOOS}_${GOARCH}.tar.gz" "${binary}" LICENSE + fi + + - name: Verify release metadata + if: matrix.goos == 'linux' && matrix.goarch == 'amd64' + env: + EXPECTED_VERSION: ${{ github.ref_name }} + run: | + expected_commit="$(git rev-parse --short HEAD)" + version_json="$(./package/stackdome version -o json)" + jq -e \ + --arg version "${EXPECTED_VERSION}" \ + --arg commit "${expected_commit}" \ + '.version == $version and .commit == $commit and (.built | length > 0)' \ + <<<"${version_json}" + + - name: Upload archive + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: stackdome-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/* + if-no-files-found: error + + publish: + name: Publish GitHub release + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Download archives + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: stackdome-* + path: dist + merge-multiple: true + + - name: Generate checksum manifest + working-directory: dist + run: sha256sum *.tar.gz *.zip > checksums.txt + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "${GITHUB_REF_NAME}" dist/* --verify-tag --generate-notes --title "Stackdome CLI ${GITHUB_REF_NAME}" diff --git a/.gitignore b/.gitignore index 317a8f9..58d19a5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ bin/ /stackfile.yaml /docs/superpowers/ HANDOVER.md +.vscode/ diff --git a/cmd/stackdome/addon.go b/cmd/stackdome/addon.go index c5eb48b..f666005 100644 --- a/cmd/stackdome/addon.go +++ b/cmd/stackdome/addon.go @@ -1,14 +1,16 @@ package main import ( + "context" "fmt" "os" + "time" + "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" - "github.com/stackdome/cli/internal/cmdutil" - clierrors "github.com/stackdome/cli/internal/errors" - "github.com/stackdome/cli/internal/output" ) func newAddonCmd() *cobra.Command { @@ -43,6 +45,8 @@ func newPostgresCreateCmd() *cobra.Command { flagVersion int32 flagInstances int32 flagStorage string + flagWait bool + flagTimeout time.Duration ) cmd := &cobra.Command{ @@ -73,12 +77,29 @@ func newPostgresCreateCmd() *cobra.Command { if err != nil { return err } + if flagWait { + if created.Id == nil || *created.Id == "" { + return clierrors.New("Created postgres addon response had no ID; cannot wait for readiness.") + } + waitCtx, cancel := waitContext(cmd.Context(), flagTimeout) + defer cancel() + waitCmd := *cmd + waitCmd.SetContext(waitCtx) + created, err = waitForPostgresAddon(ctx, &waitCmd, *created.Id, created.Name) + if err := postgresWaitCommandError(cmd.Context(), waitCtx, createdName(addon, created), err); err != nil { + return err + } + } if !ctx.Formatter.IsTable() { return ctx.Formatter.PrintStructured(created) } - fmt.Fprintf(os.Stderr, "Postgres addon %q created.\n", created.Name) + if flagWait { + fmt.Fprintf(os.Stderr, "Postgres addon %q is %s.\n", created.Name, created.Status.GetState()) + } else { + fmt.Fprintf(os.Stderr, "Postgres addon %q created.\n", created.Name) + } return nil })), } @@ -88,9 +109,63 @@ func newPostgresCreateCmd() *cobra.Command { cmd.Flags().Int32Var(&flagVersion, "version", 16, "PostgreSQL major version (13-17)") cmd.Flags().Int32Var(&flagInstances, "instances", 1, "Number of instances (1-5)") cmd.Flags().StringVar(&flagStorage, "storage", "10Gi", "Storage size") + cmd.Flags().BoolVarP(&flagWait, "wait", "w", false, "Wait for the PostgreSQL addon to become ready") + cmd.Flags().DurationVar(&flagTimeout, "timeout", defaultWaitTimeout, "Maximum time to wait for the PostgreSQL addon") return cmd } +const postgresWaitPollInterval = 2 * time.Second + +func waitForPostgresAddon(ctx *cmdutil.CommandContext, cmd *cobra.Command, addonID, name string) (*openapi.PostgresAddon, error) { + ticker := time.NewTicker(postgresWaitPollInterval) + defer ticker.Stop() + + for { + addon, err := ctx.Client.GetPostgresAddon(cmd.Context(), addonID) + if err != nil { + return nil, err + } + state := "" + message := "" + if addon.Status != nil { + state = addon.Status.GetState() + message = addon.Status.GetMessage() + } + switch state { + case "Ready", "Running": + return addon, nil + case "Failed", "Error": + if message != "" { + return nil, clierrors.Newf("Postgres addon %q entered terminal state %s: %s", name, state, message) + } + return nil, clierrors.Newf("Postgres addon %q entered terminal state %s", name, state) + } + + select { + case <-cmd.Context().Done(): + return nil, cmd.Context().Err() + case <-ticker.C: + } + } +} + +func postgresWaitCommandError(parent, wait context.Context, name string, err error) error { + if parent.Err() != nil { + return clierrors.ErrUserCanceled + } + if wait.Err() == context.DeadlineExceeded { + return clierrors.Newf("Timed out waiting for postgres addon %q to become ready.", name).WithCode("TIMEOUT") + } + return err +} + +func createdName(request openapi.PostgresAddon, response *openapi.PostgresAddon) string { + if response != nil && response.Name != "" { + return response.Name + } + return request.Name +} + func newPostgresListCmd() *cobra.Command { return &cobra.Command{ Use: "list", @@ -189,8 +264,12 @@ func newPostgresDeleteCmd() *cobra.Command { return err } - fmt.Fprintf(os.Stderr, "Postgres addon %q deleted.\n", args[0]) - return nil + return printMutationResult(ctx, mutationResult{ + Status: "deleted", + Resource: "postgres_addon", + Name: args[0], + ID: addon.GetId(), + }, fmt.Sprintf("Postgres addon %q deleted.", args[0])) })), } diff --git a/cmd/stackdome/addon_test.go b/cmd/stackdome/addon_test.go new file mode 100644 index 0000000..36c5361 --- /dev/null +++ b/cmd/stackdome/addon_test.go @@ -0,0 +1,264 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "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) { + 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) + } + 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 _, 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()) + } + } + } +} + +func TestPostgresShortcutAndLegacyPathsRouteCreateAndListEquivalently(t *testing.T) { + type request struct { + method string + path string + body string + } + var ( + mu sync.Mutex + requests []request + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + mu.Lock() + requests = append(requests, request{method: r.Method, path: r.URL.Path, body: string(body)}) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodPost: + _, _ = w.Write([]byte(postgresAddonJSON)) + case http.MethodGet: + _, _ = w.Write([]byte(`{"items":[` + postgresAddonJSON + `]}`)) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + 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) + 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]) + } + + mu.Lock() + defer mu.Unlock() + if len(requests) != 4 { + t.Fatalf("requests = %#v, want four", 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 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) { + tests := []struct { + name string + path []string + state string + }{ + {name: "shortcut ready", path: []string{"postgres"}, state: "Ready"}, + {name: "legacy running", path: []string{"addon", "postgres"}, state: "Running"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gets int + server := postgresWaitServer(t, func(w http.ResponseWriter, r *http.Request) { + gets++ + _, _ = w.Write([]byte(postgresAddonWithStatus(tt.state, ""))) + }) + defer server.Close() + configurePostgresCLI(t, server.URL) + + args := append(append([]string{}, tt.path...), "create", "demo", "--wait", "--timeout", "1s", "-o", "json") + stdout, stderr, code := runPostgresCLI(args) + if code != 0 { + t.Fatalf("exit = %d, stderr = %s", code, stderr) + } + if gets != 1 { + t.Errorf("addon GET count = %d, want 1", gets) + } + var result struct { + Status struct { + State string `json:"state"` + } `json:"status"` + } + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("stdout is not addon JSON: %v\n%s", err, stdout) + } + if result.Status.State != tt.state { + t.Errorf("state = %q, want %q", result.Status.State, tt.state) + } + }) + } +} + +func TestPostgresCreateWaitFailsForTerminalFailureStates(t *testing.T) { + for _, state := range []string{"Failed", "Error"} { + t.Run(state, func(t *testing.T) { + server := postgresWaitServer(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(postgresAddonWithStatus(state, "database provisioning failed"))) + }) + defer server.Close() + configurePostgresCLI(t, server.URL) + + stdout, stderr, code := runPostgresCLI([]string{"postgres", "create", "demo", "--wait", "--timeout", "1s", "-o", "json"}) + if code == 0 { + t.Fatal("exit = 0, want failure") + } + if stdout != "" { + t.Errorf("stdout = %q, want empty", stdout) + } + if !strings.Contains(stderr, state) || !strings.Contains(stderr, "database provisioning failed") { + t.Errorf("stderr omitted terminal failure details: %s", stderr) + } + }) + } +} + +func TestPostgresCreateWaitTimeoutIsBounded(t *testing.T) { + server := postgresWaitServer(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(postgresAddonWithStatus("Pending", ""))) + }) + defer server.Close() + configurePostgresCLI(t, server.URL) + + started := time.Now() + stdout, stderr, code := runPostgresCLI([]string{"postgres", "create", "demo", "--wait", "--timeout", "20ms", "-o", "json"}) + if code == 0 { + t.Fatal("exit = 0, want timeout") + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("timeout took %s, want under 1s", elapsed) + } + if stdout != "" { + t.Errorf("stdout = %q, want empty", stdout) + } + if !strings.Contains(stderr, "Timed out waiting for postgres addon") { + t.Errorf("stderr = %s, want postgres timeout error", stderr) + } +} + +func postgresWaitServer(t *testing.T, get func(http.ResponseWriter, *http.Request)) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/addons/postgres": + _, _ = w.Write([]byte(postgresAddonJSON)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/addons/postgres/pg-1": + get(w, r) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func postgresAddonWithStatus(state, message string) string { + status := `"state":` + strconv.Quote(state) + if message != "" { + status += `,"message":` + strconv.Quote(message) + } + return strings.Replace(postgresAddonJSON, `"state":"Pending"`, status, 1) +} + +func configurePostgresCLI(t *testing.T, serverURL string) { + t.Helper() + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + for _, name := range []string{"STACKDOME_URL", "STACKDOME_TOKEN", "STACKDOME_ORG", "STACKDOME_PROJECT"} { + t.Setenv(name, "") + } + cfg := &config.Config{ + ServerURL: serverURL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "proj-1", + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } +} + +func runPostgresCLI(args []string) (stdout, stderr string, code int) { + var out, errOut bytes.Buffer + code = runWithWriters(args, &out, &errOut) + return out.String(), errOut.String(), code +} diff --git a/cmd/stackdome/api.go b/cmd/stackdome/api.go new file mode 100644 index 0000000..a2c2062 --- /dev/null +++ b/cmd/stackdome/api.go @@ -0,0 +1,235 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "sort" + "strings" + + "github.com/Stackdome/stackdome-cli/internal/client" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/output" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +func newAPICmd() *cobra.Command { + var method string + var data string + var dataFile string + var headerFlags []string + var assumeYes bool + + cmd := &cobra.Command{ + Use: "api PATH", + Short: "Send an authenticated request to a Stackdome API path", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + if err := ctx.Config.RequireAuth(); err != nil { + return err + } + if err := validateAPIPath(args[0]); err != nil { + return err + } + + method = strings.ToUpper(method) + if !isAPIMethod(method) { + return clierrors.ValidationError(fmt.Sprintf("unsupported HTTP method %q", method)) + } + if cmd.Flags().Changed("data") && cmd.Flags().Changed("data-file") { + return clierrors.ValidationError("--data and --data-file cannot be used together") + } + + headers, err := parseAPIHeaders(headerFlags) + if err != nil { + return err + } + if headers.Get("Accept") == "" { + headers.Set("Accept", "application/json") + } + if headers.Get("Content-Type") == "" { + headers.Set("Content-Type", "application/json") + } + + body := []byte(data) + if dataFile != "" { + body, err = os.ReadFile(dataFile) + if err != nil { + return clierrors.Wrapf(err, "read request body from %s", dataFile) + } + } + + if isMutatingAPIMethod(method) { + if _, err := cmdutil.Confirm(ctx.Formatter, fmt.Sprintf("Send %s request to %s?", method, args[0]), assumeYes); err != nil { + return err + } + } + + response, err := ctx.Client.APIRequest(cmd.Context(), method, args[0], headers, body) + if err != nil { + return client.WrapError(nil, err, "API request failed") + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return client.WrapError(&http.Response{StatusCode: response.StatusCode, Header: response.Header}, errors.New(safeAPIErrorReason(response.Body, ctx.Config.AccessToken, ctx.Config.RefreshToken)), "API request failed") + } + return writeAPIResponse(ctx.Formatter, response.Body) + }), + } + + cmd.Flags().StringVarP(&method, "method", "X", http.MethodGet, "HTTP method (GET, HEAD, POST, PUT, PATCH, DELETE)") + cmd.Flags().StringVar(&data, "data", "", "JSON request body") + cmd.Flags().StringVar(&dataFile, "data-file", "", "Path to a JSON request body") + cmd.Flags().StringArrayVarP(&headerFlags, "header", "H", nil, "Request header (Name: value; repeatable)") + cmd.Flags().BoolVar(&assumeYes, "yes", false, "Skip confirmation for mutating requests") + return cmd +} + +func validateAPIPath(path string) error { + if err := client.ValidateAPIPath(path); err != nil { + return clierrors.ValidationError("PATH must be a relative path beginning with /api/ and without a fragment") + } + return nil +} + +func isAPIMethod(method string) bool { + switch method { + case http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + return true + default: + return false + } +} + +func isMutatingAPIMethod(method string) bool { + switch method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + return true + default: + return false + } +} + +func parseAPIHeaders(values []string) (http.Header, error) { + headers := make(http.Header) + for _, value := range values { + name, headerValue, ok := strings.Cut(value, ":") + name = strings.TrimSpace(name) + if !ok || name == "" { + return nil, clierrors.ValidationError(fmt.Sprintf("invalid header %q; use Name: value", value)) + } + if isProtectedAPIHeader(name) { + return nil, clierrors.ValidationError(fmt.Sprintf("header %q cannot be overridden", name)) + } + headers.Add(name, strings.TrimSpace(headerValue)) + } + return headers, nil +} + +func isProtectedAPIHeader(name string) bool { + for _, protected := range []string{"Authorization", "Proxy-Authorization", "Host", "Cookie"} { + if strings.EqualFold(name, protected) { + return true + } + } + return false +} + +func writeAPIResponse(formatter *output.Formatter, body []byte) error { + if len(body) == 0 { + return nil + } + if formatter.Format != output.FormatYAML { + _, err := formatter.Writer.Write(body) + return err + } + + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var response any + if err := decoder.Decode(&response); err != nil { + return clierrors.Wrap(err, "response is not valid JSON for YAML output") + } + if err := requireJSONEOF(decoder); err != nil { + return clierrors.Wrap(err, "response is not valid JSON for YAML output") + } + node, err := jsonYAMLNode(response) + if err != nil { + return err + } + encoder := yaml.NewEncoder(formatter.Writer) + encoder.SetIndent(2) + defer encoder.Close() + return encoder.Encode(node) +} + +func requireJSONEOF(decoder *json.Decoder) error { + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return errors.New("multiple JSON values") + } + return err + } + return nil +} + +func jsonYAMLNode(value any) (*yaml.Node, error) { + switch value := value.(type) { + case map[string]any: + node := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + child := value[key] + childNode, err := jsonYAMLNode(child) + if err != nil { + return nil, err + } + node.Content = append(node.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, childNode) + } + return node, nil + case []any: + node := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + for _, child := range value { + childNode, err := jsonYAMLNode(child) + if err != nil { + return nil, err + } + node.Content = append(node.Content, childNode) + } + return node, nil + case json.Number: + tag := "!!int" + if strings.ContainsAny(value.String(), ".eE") { + tag = "!!float" + } + return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: value.String()}, nil + case string: + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}, nil + case bool: + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: fmt.Sprint(value)}, nil + case nil: + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil + default: + return nil, clierrors.New("response contains unsupported JSON value") + } +} + +func safeAPIErrorReason(body []byte, secrets ...string) string { + var response struct { + Reason string `json:"reason"` + } + if err := json.Unmarshal(body, &response); err != nil || response.Reason == "" { + return "API request failed" + } + return redactSecrets(response.Reason, secrets...) +} diff --git a/cmd/stackdome/api_test.go b/cmd/stackdome/api_test.go new file mode 100644 index 0000000..0a68421 --- /dev/null +++ b/cmd/stackdome/api_test.go @@ -0,0 +1,377 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + "github.com/Stackdome/stackdome-cli/internal/config" + "github.com/Stackdome/stackdome-cli/internal/output" +) + +func apiTestCommand(serverURL string, format output.Format) (*bytes.Buffer, *bytes.Buffer, *cmdutil.CommandContext) { + ctx := cmdutil.NewCommandContext(&config.Config{ServerURL: serverURL, AccessToken: "sdm_test"}, format, slog.LevelError) + return &bytes.Buffer{}, &bytes.Buffer{}, ctx +} + +// StringSlice treats a comma as CSV syntax, but HTTP header values commonly +// contain commas and must arrive at the server byte-for-byte. +func TestAPICommandPreservesCommaHeaderValuesAndRepeatedHeaders(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.Header.Values("Accept"), []string{"application/json, text/plain"}; strings.Join(got, "|") != strings.Join(want, "|") { + t.Errorf("Accept = %q, want %q", got, want) + } + if got, want := r.Header.Values("X-Trace"), []string{"one", "two"}; strings.Join(got, "|") != strings.Join(want, "|") { + t.Errorf("X-Trace = %q, want %q", got, want) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + if _, err := executeAPITestCommand(t, server.URL, output.FormatJSON, + "/api/v1/test", "--header", "Accept: application/json, text/plain", "--header", "X-Trace: one", "--header", "X-Trace: two"); err != nil { + t.Fatalf("api command: %v", err) + } +} + +func executeAPITestCommand(t *testing.T, serverURL string, format output.Format, args ...string) (string, error) { + t.Helper() + stdout, stderr, ctx := apiTestCommand(serverURL, format) + ctx.Formatter.Writer = stdout + cmd := newAPICmd() + cmd.SilenceUsage = true + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.String(), err +} + +// Reintroducing RequireAuth here would discover organization/project before +// the arbitrary API call, which breaks tokens scoped only to that endpoint. +func TestAPICommandDefaultsToAuthenticatedGETWithoutScopeDiscovery(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.Method, http.MethodGet; got != want { + t.Errorf("method = %s, want %s", got, want) + } + if got, want := r.URL.RequestURI(), "/api/v1/users/current?verbose=true"; got != want { + t.Errorf("request URI = %q, want %q", got, want) + } + if got, want := r.Header.Get("Authorization"), "Bearer sdm_test"; got != want { + t.Errorf("Authorization = %q, want %q", got, want) + } + if got, want := r.Header.Values("X-Trace"), []string{"one", "two"}; strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("X-Trace = %q, want %q", got, want) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"user-1"}`)) + })) + defer server.Close() + + stdout, err := executeAPITestCommand(t, server.URL, output.FormatJSON, + "/api/v1/users/current?verbose=true", "--header", "X-Trace: one", "--header", "X-Trace: two") + if err != nil { + t.Fatalf("api command: %v", err) + } + if got, want := stdout, `{"id":"user-1"}`; got != want { + t.Errorf("stdout = %q, want %q", got, want) + } +} + +// Dropping a supported verb silently changes the requested API operation. +func TestAPICommandSupportsAllContractMethods(t *testing.T) { + for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { + t.Run(method, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Method; got != method { + t.Errorf("method = %q, want %q", got, method) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + args := []string{"/api/v1/test", "--method", method} + if method != http.MethodGet && method != http.MethodHead { + args = append(args, "--yes") + } + stdout, err := executeAPITestCommand(t, server.URL, output.FormatJSON, args...) + if err != nil { + t.Fatalf("api command: %v", err) + } + if stdout != "" { + t.Errorf("stdout = %q, want empty for 204", stdout) + } + }) + } +} + +func TestAPICommandRejectsUnsupportedMethod(t *testing.T) { + stdout, err := executeAPITestCommand(t, "http://127.0.0.1:1", output.FormatJSON, "/api/v1/test", "--method", "OPTIONS") + if err == nil { + t.Fatal("api command error = nil, want method validation error") + } + if stdout != "" { + t.Errorf("stdout = %q, want empty", stdout) + } +} + +// Choosing both data sources is ambiguous and must fail before a request is +// sent; a JSON body also needs the default content type for the API contract. +func TestAPICommandHandlesJSONDataSources(t *testing.T) { + t.Run("data sets JSON content type", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.Header.Get("Content-Type"), "application/json"; got != want { + t.Errorf("Content-Type = %q, want %q", got, want) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + if got, want := string(body), `{"name":"demo"}`; got != want { + t.Errorf("body = %q, want %q", got, want) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + if _, err := executeAPITestCommand(t, server.URL, output.FormatJSON, "/api/v1/test", "-X", "POST", "--data", `{"name":"demo"}`, "--yes"); err != nil { + t.Fatalf("api command: %v", err) + } + }) + + t.Run("data file sends its JSON body", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "request.json") + if err := os.WriteFile(path, []byte(`{"name":"file"}`), 0600); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + if got, want := string(body), `{"name":"file"}`; got != want { + t.Errorf("body = %q, want %q", got, want) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + if _, err := executeAPITestCommand(t, server.URL, output.FormatJSON, "/api/v1/test", "-X", "POST", "--data-file", path, "--yes"); err != nil { + t.Fatalf("api command: %v", err) + } + }) + + t.Run("data and data file are exclusive", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "request.json") + if err := os.WriteFile(path, []byte(`{"name":"file"}`), 0600); err != nil { + t.Fatal(err) + } + stdout, err := executeAPITestCommand(t, "http://127.0.0.1:1", output.FormatJSON, + "/api/v1/test", "--data", `{"name":"inline"}`, "--data-file", path, "--yes") + if err == nil { + t.Fatal("api command error = nil, want mutually exclusive data error") + } + if stdout != "" { + t.Errorf("stdout = %q, want empty", stdout) + } + }) +} + +// Mutating API methods must not turn a non-interactive invocation into an +// accidental write when --yes is absent. +func TestAPICommandMutatingRequestRequiresYesWithoutSending(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + stdout, err := executeAPITestCommand(t, server.URL, output.FormatJSON, "/api/v1/test", "-X", "DELETE") + if err == nil { + t.Fatal("api command error = nil, want confirmation error") + } + if requests != 0 { + t.Errorf("requests = %d, want 0", requests) + } + if stdout != "" { + t.Errorf("stdout = %q, want empty", stdout) + } +} + +// Permitting any spelling of these headers lets a user replace CLI-managed +// credentials or routing before the authenticated request is made. +func TestAPICommandRejectsCredentialBearingHeaderOverrides(t *testing.T) { + for _, header := range []string{ + "aUtHoRiZaTiOn: Bearer replacement", + "PrOxY-AuThOrIzAtIoN: Basic replacement", + "hOsT: attacker.test", + "cOoKiE: session=stolen", + } { + t.Run(header, func(t *testing.T) { + stdout, err := executeAPITestCommand(t, "http://127.0.0.1:1", output.FormatJSON, + "/api/v1/test", "--header", header) + if err == nil { + t.Fatal("api command error = nil, want protected-header rejection") + } + if stdout != "" { + t.Errorf("stdout = %q, want empty", stdout) + } + }) + } +} + +func TestAPICommandSuccessfulOutputHandling(t *testing.T) { + t.Run("204 writes no stdout", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + stdout, err := executeAPITestCommand(t, server.URL, output.FormatJSON, "/api/v1/test") + if err != nil { + t.Fatalf("api command: %v", err) + } + if stdout != "" { + t.Errorf("stdout = %q, want empty", stdout) + } + }) + + t.Run("yaml converts JSON response", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"voyager","enabled":true}`)) + })) + defer server.Close() + + stdout, err := executeAPITestCommand(t, server.URL, output.FormatYAML, "/api/v1/test") + if err != nil { + t.Fatalf("api command: %v", err) + } + if got, want := stdout, "enabled: true\nname: voyager\n"; got != want { + t.Errorf("stdout = %q, want %q", got, want) + } + }) + + t.Run("yaml preserves large JSON integers", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"id":9007199254740993}`)) + })) + defer server.Close() + + stdout, err := executeAPITestCommand(t, server.URL, output.FormatYAML, "/api/v1/test") + if err != nil { + t.Fatalf("api command: %v", err) + } + if got, want := stdout, "id: 9007199254740993\n"; got != want { + t.Errorf("stdout = %q, want %q", got, want) + } + }) +} + +func TestAPICommandRejectsPathsEscapingAPI(t *testing.T) { + for _, path := range []string{ + "/api/../settings", + "/api/%2e%2e/settings", + "/api/%2E%2E%2Fsettings", + "/api/%252e%252e/settings", + "/api/%252E%252E%252Fsettings", + } { + t.Run(path, func(t *testing.T) { + if err := validateAPIPath(path); err == nil { + t.Fatal("validateAPIPath error = nil, want API-prefix escape rejection") + } + }) + } +} + +func TestAPICommandRejectsBareAPIPath(t *testing.T) { + if err := validateAPIPath("/api"); err == nil { + t.Fatal("validateAPIPath error = nil, want bare /api rejection") + } +} + +// Streaming an error response to stdout lets automation mistake a failed +// request for a valid result. +func TestAPICommandDoesNotWriteNon2xxBodyToStdout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"reason":"invalid API request"}`)) + })) + defer server.Close() + + stdout, err := executeAPITestCommand(t, server.URL, output.FormatJSON, "/api/v1/test") + if err == nil { + t.Fatal("api command error = nil, want HTTP error") + } + if stdout != "" { + t.Errorf("stdout = %q, want empty", stdout) + } +} + +// The configured API token must not be echoed to either stream even if a +// server returns it in a non-2xx reason. +func TestAPICommandDoesNotLeakConfiguredTokenToOutput(t *testing.T) { + const token = "sdm_secret_token" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.Header.Get("Authorization"), "Bearer "+token; got != want { + t.Errorf("Authorization = %q, want %q", got, want) + } + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"reason":"request rejected for ` + token + `"}`)) + })) + defer server.Close() + + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configPath, []byte(fmt.Sprintf(`{"server_url":%q,"access_token":%q}`, server.URL, token)), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("STACKDOME_CONFIG", configPath) + var stdout, stderr bytes.Buffer + code := runWithWriters([]string{"--output", "json", "api", "/api/v1/test"}, &stdout, &stderr) + if code == 0 { + t.Fatal("exit code = 0, want HTTP error") + } + if strings.Contains(stdout.String(), token) || strings.Contains(stderr.String(), token) { + t.Errorf("token leaked: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } +} + +// API errors are JSON-decoded by client.WrapError. Redaction must therefore +// happen after decoding, or JSON escape sequences reveal configured secrets. +func TestAPICommandDoesNotLeakJSONEscapedConfiguredSecrets(t *testing.T) { + const accessToken = "sdm_secret_token" + const refreshToken = "refresh_secret_token" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"reason":"rejected sdm\u005fsecret\u005ftoken and refresh\u005fsecret\u005ftoken"}`)) + })) + defer server.Close() + + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configPath, []byte(fmt.Sprintf(`{"server_url":%q,"access_token":%q,"refresh_token":%q}`, server.URL, accessToken, refreshToken)), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("STACKDOME_CONFIG", configPath) + var stdout, stderr bytes.Buffer + code := runWithWriters([]string{"--output", "json", "api", "/api/v1/test"}, &stdout, &stderr) + if code == 0 { + t.Fatal("exit code = 0, want HTTP error") + } + for _, secret := range []string{accessToken, refreshToken} { + if strings.Contains(stdout.String(), secret) || strings.Contains(stderr.String(), secret) { + t.Errorf("configured secret leaked: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + } +} diff --git a/cmd/stackdome/auth_output_test.go b/cmd/stackdome/auth_output_test.go new file mode 100644 index 0000000..e42f244 --- /dev/null +++ b/cmd/stackdome/auth_output_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func TestTokenLoginJSONPrintsNonSecretAuthenticationResult(t *testing.T) { + 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/users/current": + _, _ = w.Write([]byte(`{"id":"user-1","email":"agent@example.com","username":"Agent","organisation_id":"org-1","role":"OrgAdmin"}`)) + case "/api/v1/users/current/projects": + _, _ = w.Write([]byte(`{"items":[{"id":"project-1","name":"default","default_project":true}],"total":1}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + const token = "sdm_must_not_appear_in_output" + var stdout, stderr bytes.Buffer + code := runWithWriters([]string{"login", "--url", ts.URL, "--token", token, "--insecure", "-o", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty structured success", stderr.String()) + } + if strings.Contains(stdout.String(), token) { + t.Fatalf("stdout leaked API token: %s", stdout.String()) + } + + var got authenticationResult + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("stdout is not a JSON authentication result: %v\nstdout: %s", err, stdout.String()) + } + if !got.Authenticated || got.User != "Agent" || got.OrganizationID != "org-1" || got.Project != "default" || got.ServerURL != ts.URL || got.AuthMethod != "api_token" { + t.Errorf("authentication result = %#v", got) + } +} + +func TestLogoutJSONPrintsResult(t *testing.T) { + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + var stdout, stderr bytes.Buffer + code := runWithWriters([]string{"logout", "-o", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty structured success", stderr.String()) + } + var got struct { + LoggedOut bool `json:"logged_out"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("stdout is not a JSON logout result: %v\nstdout: %s", err, stdout.String()) + } + if !got.LoggedOut { + t.Errorf("logout result = %#v, want logged_out=true", got) + } +} diff --git a/cmd/stackdome/build.go b/cmd/stackdome/build.go index 807dc34..c4b166b 100644 --- a/cmd/stackdome/build.go +++ b/cmd/stackdome/build.go @@ -1,16 +1,17 @@ package main import ( + "context" "fmt" "os" "time" + "github.com/Stackdome/stackdome-cli/internal/client" + "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" - "github.com/stackdome/cli/internal/client" - "github.com/stackdome/cli/internal/cmdutil" - clierrors "github.com/stackdome/cli/internal/errors" - "github.com/stackdome/cli/internal/output" ) func newBuildCmd() *cobra.Command { @@ -38,6 +39,9 @@ func newBuildLogsCmd() *cobra.Command { Short: "Stream logs for a build", Args: cobra.ExactArgs(1), RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + if err := output.ValidateStreamingFormat(ctx.Formatter.Format); err != nil { + return err + } stackID, err := resolveStackID(ctx, cmd, flagStack) if err != nil { return err @@ -54,21 +58,26 @@ func newBuildLogsCmd() *cobra.Command { Since: flagSince, }) if err != nil { + if cmd.Context().Err() == context.Canceled { + return clierrors.ErrUserCanceled + } return err } defer stream.Close() - return client.ParseSSEStream(stream, func(e client.SSEEvent) error { + err = client.ParseSSEStream(stream, func(e client.SSEEvent) error { if e.Event == "error" { - fmt.Fprintf(os.Stderr, "Error: %s\n", e.Data) return clierrors.New(e.Data) } if e.IsEnd() { return nil } - fmt.Println(e.Data) - return nil + return printLogEvent(ctx.Formatter, e) }) + if cmd.Context().Err() == context.Canceled { + return clierrors.ErrUserCanceled + } + return err })), } diff --git a/cmd/stackdome/build_test.go b/cmd/stackdome/build_test.go index 82f422f..4da483f 100644 --- a/cmd/stackdome/build_test.go +++ b/cmd/stackdome/build_test.go @@ -1,12 +1,64 @@ package main import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" "testing" "time" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + "github.com/Stackdome/stackdome-cli/internal/config" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/output" "github.com/Stackdome/stackdome/pkg/api/openapi" ) +func TestBuildLogsRejectsYAMLStreamOutput(t *testing.T) { + const stackID = "b02262ac-8e6e-45cd-b18e-acb5d3f97ce4" + requests := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/organizations/org-1/projects/proj-1/stacks/" + stackID + "/builds": + _, _ = w.Write([]byte(`{"items":[{"id":"build-1"}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/" + stackID + "/builds/build-1/logs": + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: end\ndata: {}\n\n")) + 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.FormatYAML, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + cmd := newBuildLogsCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"build-1"}) + + err := cmd.Execute() + var cliErr *clierrors.CLIError + if !errors.As(err, &cliErr) || cliErr.Code != "VALIDATION_ERROR" { + t.Fatalf("build logs YAML error = %T (%v), want validation error", err, err) + } + if stdout.Len() != 0 || requests != 0 { + t.Errorf("stdout = %q, requests = %d; want rejection before streaming", stdout.String(), requests) + } +} + func cond(typ string, t time.Time) openapi.Condition { return openapi.Condition{Type: &typ, LastTransitionTime: &t} } @@ -119,3 +171,176 @@ func TestBuildDurationInProgressShowsElapsed(t *testing.T) { t.Errorf("duration = %q, want %q", d, "1m30s") } } + +// Build logs share the runtime log contract: each JSON stream item is a +// compact NDJSON event with its JSON data decoded exactly once. +func TestBuildLogsJSONWritesDecodedNDJSONEvent(t *testing.T) { + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/builds": + _, _ = w.Write([]byte(`{"items":[{"id":"build-1"}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/builds/build-1/logs": + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: build\ndata: {\"phase\":\"compile\"}\n\nevent: build\ndata: {\"phase\":\"package\"}\n\nevent: end\ndata: {}\n\n")) + 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"}, output.FormatJSON, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + cmd := newBuildLogsCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--stack", "app", "build-1"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("build logs: %v", err) + } + + if bytes.Contains(stdout.Bytes(), []byte("\n ")) { + t.Fatalf("build log stream is indented instead of compact: %q", stdout.String()) + } + var lines []struct { + Event string `json:"event"` + Data struct { + Phase string `json:"phase"` + } `json:"data"` + } + scanner := bufio.NewScanner(bytes.NewReader(stdout.Bytes())) + for scanner.Scan() { + var line struct { + Event string `json:"event"` + Data struct { + Phase string `json:"phase"` + } `json:"data"` + } + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { + t.Fatalf("build log line is not independent JSON: %v\nline: %s", err, scanner.Text()) + } + lines = append(lines, line) + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan build log output: %v", err) + } + if len(lines) != 2 { + t.Fatalf("decoded %d lines, want 2: %s", len(lines), stdout.String()) + } + if lines[0].Event != "build" || lines[0].Data.Phase != "compile" || lines[1].Event != "build" || lines[1].Data.Phase != "package" { + t.Errorf("lines = %#v, want compile and package build events", lines) + } +} + +// Build-stream errors follow the same root-only JSON error contract as +// 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)) + } + + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/builds": + _, _ = w.Write([]byte(`{"items":[{"id":"build-1"}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/builds/build-1/logs": + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: error\ndata: build stream failed\n\n")) + 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) + } + + child := exec.Command(os.Args[0], "-test.run=^TestBuildLogsJSONServerErrorIsSingleRootDocument$") + child.Env = append(os.Environ(), "STACKDOME_TEST_BUILD_LOG_ERROR_HELPER=1", "STACKDOME_CONFIG="+configPath) + var stdout, stderr bytes.Buffer + child.Stdout = &stdout + child.Stderr = &stderr + if err := child.Run(); err == nil { + t.Fatal("build logs process succeeded, want server stream failure") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + var got struct { + Error string `json:"error"` + ExitCode int `json:"exit_code"` + } + if err := json.Unmarshal(stderr.Bytes(), &got); err != nil { + t.Fatalf("stderr is not one JSON document: %v\nstderr: %s", err, stderr.String()) + } + if got.Error != "build stream failed" || got.ExitCode == 0 { + t.Errorf("error document = %#v, want build stream failure", got) + } +} + +// The build-log stream shares the runtime log interruption contract: once the +// parent context is cancelled after parsing begins, the command returns the +// user-cancelled sentinel rather than the transport read error. +func TestBuildLogsCancellationAfterStreamStartsIsUserCancellation(t *testing.T) { + const stackID = "b02262ac-8e6e-45cd-b18e-acb5d3f97ce4" + started := make(chan struct{}) + 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 + "/builds": + _, _ = w.Write([]byte(`{"items":[{"id":"build-1"}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/" + stackID + "/builds/build-1/logs": + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + close(started) + <-r.Context().Done() + 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 + parent, cancel := context.WithCancel(context.Background()) + defer cancel() + cmd := newBuildLogsCmd() + cmd.SetContext(parent) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"build-1"}) + + errCh := make(chan error, 1) + go func() { errCh <- cmd.Execute() }() + select { + case <-started: + cancel() + case <-time.After(time.Second): + t.Fatal("build log stream did not start") + } + select { + case err := <-errCh: + if err != clierrors.ErrUserCanceled { + t.Fatalf("cancellation error = %v, want ErrUserCanceled", err) + } + case <-time.After(time.Second): + t.Fatal("build logs command did not return after cancellation") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } +} diff --git a/cmd/stackdome/config.go b/cmd/stackdome/config.go index 88e0ddb..182c092 100644 --- a/cmd/stackdome/config.go +++ b/cmd/stackdome/config.go @@ -4,9 +4,9 @@ import ( "fmt" "os" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/cmdutil" - clierrors "github.com/stackdome/cli/internal/errors" ) func newConfigCmd() *cobra.Command { @@ -54,30 +54,28 @@ func newConfigSetStackCmd() *cobra.Command { Use: "set-stack ", Short: "Set the current stack context (name or ID)", Args: cobra.ExactArgs(1), - RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { - id, err := resolveStackRef(ctx, cmd, args[0]) - if err != nil { - return err - } - if err := ctx.Config.SetCurrentStack(id); err != nil { - return err - } - note := "" - if ctx.Config.TokenFromEnv() { - note = " — this session only, not persisted with env-token auth" - } - fmt.Fprintf(os.Stderr, "Current stack set to %s (%s)%s\n", args[0], id, note) - return nil - })), + RunE: cmdutil.WithContext(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + return selectStackContext(ctx, cmd, args[0], "config set-stack") + }), } } +type contextSwitchResult struct { + Status string `json:"status" yaml:"status"` + Resource string `json:"resource" yaml:"resource"` + ServerURL string `json:"server_url" yaml:"server_url"` + Authenticated bool `json:"authenticated" yaml:"authenticated"` +} + func newConfigSetContextCmd() *cobra.Command { return &cobra.Command{ Use: "set-context ", Short: "Switch to a different Stackdome server", 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") + } newURL := args[0] if newURL == "" { return clierrors.ValidationError("URL cannot be empty") @@ -95,6 +93,14 @@ func newConfigSetContextCmd() *cobra.Command { return err } + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(contextSwitchResult{ + Status: "switched", + Resource: "context", + ServerURL: newURL, + Authenticated: false, + }) + } fmt.Fprintf(os.Stderr, "Context switched to %s. Run `stackdome login` to authenticate.\n", newURL) return nil }), diff --git a/cmd/stackdome/config_output_test.go b/cmd/stackdome/config_output_test.go new file mode 100644 index 0000000..7378ef7 --- /dev/null +++ b/cmd/stackdome/config_output_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "bytes" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/Stackdome/stackdome-cli/internal/config" +) + +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) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty structured success", stderr.String()) + } + var got struct { + Status string `json:"status"` + Resource string `json:"resource"` + ServerURL string `json:"server_url"` + Authenticated bool `json:"authenticated"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("stdout is not a JSON context result: %v\nstdout: %s", err, stdout.String()) + } + if got.Status != "switched" || got.Resource != "context" || got.ServerURL != "https://example.stackdome.test" || got.Authenticated { + t.Errorf("context result = %#v", got) + } +} + +func TestConfigSetStackRejectsEphemeralEnvTokenContext(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + t.Setenv("STACKDOME_CONFIG", configPath) + t.Setenv("STACKDOME_URL", "https://example.stackdome.test") + t.Setenv("STACKDOME_TOKEN", "sdm_ephemeral") + t.Setenv("STACKDOME_ORG", "org-1") + t.Setenv("STACKDOME_PROJECT", "default") + + var stdout, stderr bytes.Buffer + code := runWithWriters([]string{"config", "set-stack", "app", "-o", "json"}, &stdout, &stderr) + if code == 0 { + t.Fatal("set-stack succeeded even though an env-token process cannot persist the selection") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty on failure", stdout.String()) + } + if !strings.Contains(stderr.String(), "--stack") { + t.Fatalf("stderr = %q, want --stack remediation", stderr.String()) + } + if _, err := config.LoadFrom(configPath); err != nil { + t.Fatalf("config should remain readable: %v", err) + } +} + +func TestConfigSetContextRejectsEnvironmentOverrides(t *testing.T) { + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + t.Setenv("STACKDOME_URL", "https://old.example") + t.Setenv("STACKDOME_TOKEN", "sdm_ephemeral") + + var stdout, stderr bytes.Buffer + code := runWithWriters([]string{"config", "set-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") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty on failure", stdout.String()) + } + if !strings.Contains(stderr.String(), "STACKDOME_URL") || !strings.Contains(stderr.String(), "STACKDOME_TOKEN") { + t.Fatalf("stderr = %q, want environment override remediation", stderr.String()) + } +} diff --git a/cmd/stackdome/deploy.go b/cmd/stackdome/deploy.go index 2c097bf..29a0270 100644 --- a/cmd/stackdome/deploy.go +++ b/cmd/stackdome/deploy.go @@ -2,25 +2,28 @@ package main import ( "context" + "encoding/json" "fmt" "os" "path/filepath" "strings" + "time" + "github.com/Stackdome/stackdome-cli/internal/client" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/output" + "github.com/Stackdome/stackdome-cli/internal/stackfile" openapi "github.com/Stackdome/stackdome/pkg/api/openapi" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/client" - "github.com/stackdome/cli/internal/cmdutil" - clierrors "github.com/stackdome/cli/internal/errors" - "github.com/stackdome/cli/internal/output" - "github.com/stackdome/cli/internal/stackfile" ) func newDeployCmd() *cobra.Command { var ( - flagFile string - flagName string - flagWait bool + flagFile string + flagName string + flagWait bool + flagTimeout time.Duration ) cmd := &cobra.Command{ @@ -43,7 +46,9 @@ the release object is the final one.`, return err } - fmt.Fprintf(os.Stderr, "Applying stack %q...\n", stack.Name) + if ctx.Formatter.IsTable() { + fmt.Fprintf(os.Stderr, "Applying stack %q...\n", stack.Name) + } result, err := ctx.Client.ApplyStack(cmd.Context(), *stack) if err != nil { return err @@ -71,18 +76,30 @@ the release object is the final one.`, return nil } - final, waitErr := followRelease(ctx, cmd, *result.Id, release.GetId()) + waitCtx, cancel := waitContext(cmd.Context(), flagTimeout) + defer cancel() + waitCmd := *cmd + waitCmd.SetContext(waitCtx) - if err := printFinalStack(ctx, cmd, *result.Id, final); err != nil && waitErr == nil { + final, waitErr := followRelease(ctx, &waitCmd, *result.Id, release.GetId()) + if err := waitCommandError(cmd.Context(), waitCtx, waitErr); err != nil { + return err + } + stack, live, err := fetchDeployObservation(ctx, &waitCmd, *result.Id, release.GetId(), final) + if err := waitCommandError(cmd.Context(), waitCtx, err); err != nil { + return err + } + if err := waitCommandError(cmd.Context(), waitCtx, nil); err != nil { return err } - return waitErr + return printFinalStack(ctx, stack, final, live) })), } cmd.Flags().StringVarP(&flagFile, "file", "f", "stackfile.yaml", "Path to stackfile or stack JSON") cmd.Flags().StringVar(&flagName, "name", "", "Override stack name") 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 } @@ -151,8 +168,9 @@ func loadStack(path, nameOverride string) (*openapi.Stack, error) { // deployResult is what `deploy -o json|yaml` prints: scripts need the release // id to follow events, which a bare stack does not carry. type deployResult struct { - Stack *openapi.Stack `json:"stack" yaml:"stack"` - Release any `json:"release" yaml:"release"` + Stack *openapi.Stack `json:"stack" yaml:"stack"` + Release any `json:"release" yaml:"release"` + LiveStatus *openapi.ReleaseLiveStatus `json:"live_status,omitempty" yaml:"live_status,omitempty"` } // followRelease streams a release's events to stderr and resolves its outcome, @@ -160,25 +178,91 @@ type deployResult struct { // release that did not reach Released is an error, so `deploy --wait` exits // non-zero on a failed deploy. func followRelease(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID, releaseID string) (*openapi.StackReleaseDetail, error) { - events, err := ctx.Client.StreamReleaseEvents(cmd.Context(), stackID, releaseID, 0) - if err != nil { - return nil, err + var afterSequence int32 + for { + events, err := ctx.Client.StreamReleaseEvents(cmd.Context(), stackID, releaseID, afterSequence) + if err != nil { + return nil, err + } + progressed := false + for e := range events { + if sequence, ok := streamedReleaseEventSequence(e.Data); ok && sequence > afterSequence { + afterSequence = sequence + progressed = true + } + if ctx.Formatter.IsTable() { + printReleaseEventLine(os.Stderr, e) + } + } + if err := releaseWaitContextError(cmd.Context()); err != nil { + return nil, err + } + + release, err := ctx.Client.GetRelease(cmd.Context(), stackID, releaseID) + if err != nil { + return nil, err + } + if release.GetId() != releaseID { + return release, deployVerificationError("requested release %q, but the server returned %q", releaseID, release.GetId()) + } + if releaseStateIsTerminal(release.GetState()) { + return release, releaseOutcomeError(release, ctx.Formatter.IsTable()) + } + + // An explicit stream end can race with a queued release beginning. Resume + // from the last event instead of treating Pending/InProgress as failure. + // Empty rounds are delayed so a server repeatedly returning `end` cannot + // create a hot request loop. + if !progressed { + timer := time.NewTimer(time.Second) + select { + case <-timer.C: + case <-cmd.Context().Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + return nil, releaseWaitContextError(cmd.Context()) + } + } } - for e := range events { - printReleaseEventLine(os.Stderr, e) +} + +func streamedReleaseEventSequence(data string) (int32, bool) { + var event struct { + Sequence *int32 `json:"sequence"` } - if cmd.Context().Err() != nil { - return nil, clierrors.ErrUserCanceled + if json.Unmarshal([]byte(data), &event) != nil || event.Sequence == nil { + return 0, false } + return *event.Sequence, true +} - release, err := ctx.Client.GetRelease(cmd.Context(), stackID, releaseID) - if err != nil { - return nil, err +func releaseWaitContextError(ctx context.Context) error { + if ctx.Err() == context.DeadlineExceeded { + return clierrors.New("Timed out waiting for the release to finish.").WithCode("TIMEOUT") + } + if ctx.Err() != nil { + return clierrors.ErrUserCanceled } - return release, releaseOutcomeError(release) + return nil } -func releaseOutcomeError(release *openapi.StackReleaseDetail) error { +func releaseStateIsTerminal(state openapi.StackReleaseState) bool { + switch state { + case openapi.RELEASE_STATE_RELEASED, + openapi.RELEASE_STATE_FAILED, + openapi.RELEASE_STATE_CANCELLED, + openapi.RELEASE_STATE_SUPERSEDED: + return true + default: + return false + } +} + +func releaseOutcomeError(release *openapi.StackReleaseDetail, printSuccess bool) error { state := "" if release.State != nil { state = string(*release.State) @@ -186,7 +270,9 @@ func releaseOutcomeError(release *openapi.StackReleaseDetail) error { switch openapi.StackReleaseState(state) { case openapi.RELEASE_STATE_RELEASED: - fmt.Fprintln(os.Stderr, output.Green("Release succeeded.")) + if printSuccess { + fmt.Fprintln(os.Stderr, output.Green("Release succeeded.")) + } return nil case openapi.RELEASE_STATE_FAILED, openapi.RELEASE_STATE_CANCELLED, @@ -228,18 +314,81 @@ func validationErrorLine(ve openapi.ReleaseValidationError) string { return strings.TrimSpace(b.String()) } -func printFinalStack(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID string, release *openapi.StackReleaseDetail) error { +func fetchDeployObservation(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID, requestedReleaseID string, release *openapi.StackReleaseDetail) (*openapi.Stack, *openapi.ReleaseLiveStatus, error) { final, err := ctx.Client.GetStack(cmd.Context(), stackID) if err != nil { - return err - } - if !ctx.Formatter.IsTable() { - return ctx.Formatter.PrintStructured(deployResult{Stack: final, Release: release}) + return nil, nil, err } live, err := ctx.Client.GetStackLiveStatus(cmd.Context(), final) if err != nil { - return err + return nil, nil, err + } + if err := verifyDeployObservation(requestedReleaseID, release, final, live); err != nil { + return nil, nil, err + } + return final, live, nil +} + +func printFinalStack(ctx *cmdutil.CommandContext, final *openapi.Stack, release *openapi.StackReleaseDetail, live *openapi.ReleaseLiveStatus) error { + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(deployResult{Stack: final, Release: release, LiveStatus: live}) } output.RenderStackStatus(os.Stdout, final, live, false) return nil } + +func verifyDeployObservation(requestedReleaseID string, release *openapi.StackReleaseDetail, stack *openapi.Stack, live *openapi.ReleaseLiveStatus) error { + if requestedReleaseID == "" { + return deployVerificationError("the requested release ID is empty") + } + if release == nil { + return deployVerificationError("release %q has no terminal response", requestedReleaseID) + } + if release.GetId() != requestedReleaseID { + return deployVerificationError("requested release %q, but the server returned %q", requestedReleaseID, release.GetId()) + } + if release.GetState() != openapi.RELEASE_STATE_RELEASED { + return deployVerificationError("release %q is %q, not Released", requestedReleaseID, release.GetState()) + } + if stack == nil || stack.ConvergedRelease == nil { + return deployVerificationError("stack has no converged release after release %q completed", requestedReleaseID) + } + if stack.ConvergedRelease.GetId() != requestedReleaseID { + return deployVerificationError("release %q completed, but stack converged release is %q", requestedReleaseID, stack.ConvergedRelease.GetId()) + } + if live == nil || live.GetHealth() != openapi.RELEASE_HEALTH_OK { + health := openapi.ReleaseHealth("") + if live != nil { + health = live.GetHealth() + } + return deployVerificationError("release %q runtime health is %q, not ok", requestedReleaseID, health) + } + + statuses := live.GetResources() + for _, resource := range stack.Spec.GetStackResources() { + for _, port := range resource.GetPorts() { + if !port.GetExposedToPublic() { + continue + } + status, ok := statuses[resource.Name] + if !ok { + return deployVerificationError("public resource %q has no live status", resource.Name) + } + found := false + for _, ingress := range status.GetPublicIngress() { + if ingress.GetTargetPort() == port.GetNumber() && strings.TrimSpace(ingress.GetUrl()) != "" { + found = true + break + } + } + if !found { + return deployVerificationError("public resource %q port %d has no public URL", resource.Name, port.GetNumber()) + } + } + } + return nil +} + +func deployVerificationError(format string, args ...any) error { + return clierrors.Newf("Deployment verification failed: "+format, args...).WithCode("DEPLOY_VERIFICATION_FAILED") +} diff --git a/cmd/stackdome/deploy_contract_test.go b/cmd/stackdome/deploy_contract_test.go new file mode 100644 index 0000000..78cfcfd --- /dev/null +++ b/cmd/stackdome/deploy_contract_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "testing" + + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" +) + +func healthyDeployObservation(public bool) (*openapi.StackReleaseDetail, *openapi.Stack, *openapi.ReleaseLiveStatus) { + releaseID := "release-requested" + state := openapi.RELEASE_STATE_RELEASED + health := openapi.RELEASE_HEALTH_OK + ports := []openapi.Port{{Name: "http", Number: 80, ExposedToPublic: public}} + stack := &openapi.Stack{ + Name: "app", + Spec: openapi.StackSpec{StackResources: []openapi.StackResource{{ + Name: "web", + Ports: ports, + }}}, + ConvergedRelease: &openapi.ReleaseSummary{Id: &releaseID, State: &state}, + } + statuses := map[string]openapi.StackResourceStatus{} + if public { + statuses["web"] = openapi.StackResourceStatus{PublicIngress: []openapi.Ingress{{ + Url: openapi.PtrString("https://app.example"), + TargetPort: openapi.PtrInt32(80), + }}} + } + return &openapi.StackReleaseDetail{Id: &releaseID, State: &state}, stack, &openapi.ReleaseLiveStatus{ + Health: &health, + Resources: &statuses, + } +} + +func TestVerifyDeployObservationEnforcesAgentSuccessContract(t *testing.T) { + tests := []struct { + name string + mutate func(*openapi.StackReleaseDetail, *openapi.Stack, *openapi.ReleaseLiveStatus) + public bool + wantOK bool + }{ + {name: "healthy private stack", public: false, wantOK: true}, + {name: "healthy public stack", public: true, wantOK: true}, + {name: "release not terminal Released", public: false, mutate: func(r *openapi.StackReleaseDetail, _ *openapi.Stack, _ *openapi.ReleaseLiveStatus) { + state := openapi.RELEASE_STATE_FAILED + r.State = &state + }}, + {name: "fetched release ID mismatch", public: false, mutate: func(r *openapi.StackReleaseDetail, _ *openapi.Stack, _ *openapi.ReleaseLiveStatus) { + r.Id = openapi.PtrString("release-other") + }}, + {name: "missing converged release", public: false, mutate: func(_ *openapi.StackReleaseDetail, s *openapi.Stack, _ *openapi.ReleaseLiveStatus) { + s.ConvergedRelease = nil + }}, + {name: "converged release mismatch", public: false, mutate: func(_ *openapi.StackReleaseDetail, s *openapi.Stack, _ *openapi.ReleaseLiveStatus) { + s.ConvergedRelease.Id = openapi.PtrString("release-other") + }}, + {name: "missing live status", public: false, mutate: func(_ *openapi.StackReleaseDetail, _ *openapi.Stack, live *openapi.ReleaseLiveStatus) { + *live = openapi.ReleaseLiveStatus{} + }}, + {name: "degraded runtime", public: false, mutate: func(_ *openapi.StackReleaseDetail, _ *openapi.Stack, live *openapi.ReleaseLiveStatus) { + health := openapi.RELEASE_HEALTH_DEGRADED + live.Health = &health + }}, + {name: "public URL missing", public: true, mutate: func(_ *openapi.StackReleaseDetail, _ *openapi.Stack, live *openapi.ReleaseLiveStatus) { + statuses := map[string]openapi.StackResourceStatus{"web": {}} + live.Resources = &statuses + }}, + {name: "wrong public target port", public: true, mutate: func(_ *openapi.StackReleaseDetail, _ *openapi.Stack, live *openapi.ReleaseLiveStatus) { + statuses := map[string]openapi.StackResourceStatus{"web": {PublicIngress: []openapi.Ingress{{ + Url: openapi.PtrString("https://app.example"), TargetPort: openapi.PtrInt32(8080), + }}}} + live.Resources = &statuses + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + release, stack, live := healthyDeployObservation(tt.public) + if tt.mutate != nil { + tt.mutate(release, stack, live) + } + err := verifyDeployObservation("release-requested", release, stack, live) + if tt.wantOK && err != nil { + t.Fatalf("verifyDeployObservation: %v", err) + } + if !tt.wantOK && err == nil { + t.Fatal("verifyDeployObservation returned nil, want verification failure") + } + }) + } +} diff --git a/cmd/stackdome/deploy_test.go b/cmd/stackdome/deploy_test.go index b62122f..30d4cd6 100644 --- a/cmd/stackdome/deploy_test.go +++ b/cmd/stackdome/deploy_test.go @@ -1,19 +1,146 @@ package main import ( + "bytes" "context" + "encoding/json" + "errors" "log/slog" "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "testing" + "time" - "github.com/stackdome/cli/internal/cmdutil" - "github.com/stackdome/cli/internal/config" - "github.com/stackdome/cli/internal/output" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + "github.com/Stackdome/stackdome-cli/internal/config" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/output" + "gopkg.in/yaml.v3" ) +func TestDeployJSONFailureHasSingleRootErrorDocument(t *testing.T) { + if os.Getenv("STACKDOME_TEST_DEPLOY_JSON_FAILURE_HELPER") == "1" { + stackfilePath := filepath.Join("..", "..", "internal", "stackfile", "testdata", "basic_image.yaml") + os.Exit(runWithWriters([]string{"deploy", "--file", stackfilePath, "-o", "json"}, os.Stdout, os.Stderr)) + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case 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":{}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases": + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"release creation failed"}`)) + 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) + } + + child := exec.Command(os.Args[0], "-test.run=^TestDeployJSONFailureHasSingleRootErrorDocument$") + child.Env = append(os.Environ(), "STACKDOME_TEST_DEPLOY_JSON_FAILURE_HELPER=1", "STACKDOME_CONFIG="+configPath) + var stdout, stderr bytes.Buffer + child.Stdout = &stdout + child.Stderr = &stderr + if err := child.Run(); err == nil { + t.Fatal("deploy process succeeded, want release creation failure") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + var got struct { + Error string `json:"error"` + ExitCode int `json:"exit_code"` + } + if err := json.Unmarshal(stderr.Bytes(), &got); err != nil { + t.Fatalf("stderr is not one JSON error document: %v\nstderr: %s", err, stderr.String()) + } + if got.Error == "" || got.ExitCode == 0 { + t.Errorf("error document = %#v, want a failure", got) + } +} + +func TestDeployWaitStructuredKeepsHumanProgressOffStderr(t *testing.T) { + if format := os.Getenv("STACKDOME_TEST_DEPLOY_STRUCTURED_WAIT_HELPER"); format != "" { + stackfilePath := filepath.Join("..", "..", "internal", "stackfile", "testdata", "basic_image.yaml") + os.Exit(runWithWriters([]string{"deploy", "--wait", "--file", stackfilePath, "-o", format}, os.Stdout, os.Stderr)) + } + + var releaseReads int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case 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":{}}`)) + 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":"rel-7","stack_id":"stack-1","sequence":3,"state":"Pending"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases/rel-7/events/stream": + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: message\ndata: {\"sequence\":1,\"message\":\"building\"}\n\nevent: end\ndata: {}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases/rel-7": + releaseReads++ + if releaseReads%2 == 1 { + _, _ = w.Write([]byte(`{"id":"rel-7","stack_id":"stack-1","state":"Released"}`)) + return + } + _, _ = w.Write([]byte(`{"id":"rel-7","stack_id":"stack-1","state":"Released","live_status":{"health":"ok"}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1": + _, _ = w.Write([]byte(`{"id":"stack-1","name":"basic-stack","spec":{},"converged_release":{"id":"rel-7"}}`)) + 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) + } + + for _, format := range []string{"json", "yaml"} { + t.Run(format, func(t *testing.T) { + child := exec.Command(os.Args[0], "-test.run=^TestDeployWaitStructuredKeepsHumanProgressOffStderr$") + child.Env = append(os.Environ(), "STACKDOME_TEST_DEPLOY_STRUCTURED_WAIT_HELPER="+format, "STACKDOME_CONFIG="+configPath) + var stdout, stderr bytes.Buffer + child.Stdout = &stdout + child.Stderr = &stderr + if err := child.Run(); err != nil { + t.Fatalf("deploy process: %v\nstderr: %s", err, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty in structured mode", stderr.String()) + } + var got deployResult + if format == "json" { + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("stdout is not one deploy JSON document: %v\nstdout: %s", err, stdout.String()) + } + } else if err := yaml.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("stdout is not one deploy YAML document: %v\nstdout: %s", err, stdout.String()) + } + if got.Stack == nil || got.Release == nil || got.LiveStatus == nil { + t.Errorf("deploy result omitted fields: %#v", got) + } + }) + } +} + // Applying a stack only stores the document — the server creates no release for // it. Deploy must follow the apply with an explicit createRelease, or nothing // ever rolls out. @@ -45,7 +172,9 @@ func TestDeployAppliesThenCreatesRelease(t *testing.T) { OrganizationID: "org-1", ProjectName: "proj-1", } - ctx := cmdutil.NewCommandContext(cfg, output.FormatTable, slog.LevelError) + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout cmd := newDeployCmd() cmd.SetContext(context.Background()) @@ -73,4 +202,237 @@ func TestDeployAppliesThenCreatesRelease(t *testing.T) { if cfg.CurrentStack != "stack-1" { t.Errorf("current stack = %q, want %q", cfg.CurrentStack, "stack-1") } + var result struct { + Stack json.RawMessage `json:"stack"` + Release json.RawMessage `json:"release"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("deploy JSON is invalid: %v\nstdout: %s", err, stdout.String()) + } + if len(result.Stack) == 0 || len(result.Release) == 0 { + t.Errorf("deploy JSON omitted stack or release: %s", stdout.String()) + } +} + +// A stalled event stream must not leave an agent blocked forever. The +// command's timeout should cancel the request and report a timeout rather than +// treating a deadline as a user interrupt. +func TestDeployWaitTimeoutBoundsEventStream(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case 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":{}}`)) + 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":"rel-timeout","stack_id":"stack-1","sequence":4,"state":"Pending"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases/rel-timeout/events/stream": + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + <-r.Context().Done() + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + cfg := &config.Config{ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", ProjectName: "proj-1"} + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + + cmd := newDeployCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--wait", "--timeout", "20ms", "--file", filepath.Join("..", "..", "internal", "stackfile", "testdata", "basic_image.yaml")}) + + started := time.Now() + err := cmd.Execute() + if err == nil { + t.Fatal("deploy --wait returned nil, want timeout error") + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("deploy timeout took %s, want under 1s", elapsed) + } + if got := stdout.String(); got != "" { + t.Fatalf("stdout = %q, want no partial result on timeout", got) + } + if err == clierrors.ErrUserCanceled { + t.Fatalf("deadline reported as user cancellation: %v", err) + } +} + +func TestDeployWaitTimeoutBeforeStreamHeadersUsesTimeoutContract(t *testing.T) { + streamStarted := make(chan struct{}) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case 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":{}}`)) + 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":"rel-timeout","stack_id":"stack-1","sequence":4,"state":"Pending"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases/rel-timeout/events/stream": + close(streamStarted) + <-r.Context().Done() + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + cfg := &config.Config{ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", ProjectName: "proj-1"} + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + + cmd := newDeployCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--wait", "--timeout", "20ms", "--file", filepath.Join("..", "..", "internal", "stackfile", "testdata", "basic_image.yaml")}) + + err := cmd.Execute() + var cliErr *clierrors.CLIError + if !errors.As(err, &cliErr) { + t.Fatalf("timeout error type = %T (%v), want *CLIError", err, err) + } + if cliErr.Code != "TIMEOUT" { + t.Fatalf("timeout code = %q, want TIMEOUT (error: %v)", cliErr.Code, err) + } + if cliErr.Message != "Timed out waiting for the release to finish." { + t.Errorf("timeout message = %q", cliErr.Message) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want no partial result", stdout.String()) + } + select { + case <-streamStarted: + default: + t.Fatal("test did not reach the unflushed event stream") + } +} + +// A waited JSON deploy is an observation boundary: consumers need both the +// terminal release and the runtime state that was fetched after it completed. +func TestDeployWaitJSONIncludesLiveStatus(t *testing.T) { + var releaseReads int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case 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":{}}`)) + 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":"rel-7","stack_id":"stack-1","sequence":3,"state":"Pending"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases/rel-7/events/stream": + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: end\ndata: {}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases/rel-7": + releaseReads++ + if releaseReads == 1 { + _, _ = w.Write([]byte(`{"id":"rel-7","stack_id":"stack-1","state":"Released"}`)) + return + } + _, _ = w.Write([]byte(`{"id":"rel-7","stack_id":"stack-1","state":"Released","live_status":{"health":"ok"}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1": + _, _ = w.Write([]byte(`{"id":"stack-1","name":"basic-stack","spec":{},"converged_release":{"id":"rel-7"}}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + cfg := &config.Config{ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", ProjectName: "proj-1"} + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + + cmd := newDeployCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--wait", "--file", filepath.Join("..", "..", "internal", "stackfile", "testdata", "basic_image.yaml")}) + if err := cmd.Execute(); err != nil { + t.Fatalf("deploy --wait: %v", err) + } + + var got struct { + Stack json.RawMessage `json:"stack"` + Release json.RawMessage `json:"release"` + LiveStatus struct { + Health string `json:"health"` + } `json:"live_status"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("deploy output is not JSON: %v\nstdout: %s", err, stdout.String()) + } + if len(got.Stack) == 0 || len(got.Release) == 0 { + t.Fatalf("result omitted stack or release: %s", stdout.String()) + } + if got.LiveStatus.Health != "ok" { + t.Errorf("live_status.health = %q, want ok", got.LiveStatus.Health) + } +} + +// The wait deadline covers the final stack observation as well as the event +// stream. A slow GetStack must not leak a transport error or a partial result. +func TestDeployWaitTimeoutDuringFinalStackObservationUsesTimeoutContract(t *testing.T) { + stackFetchStarted := make(chan struct{}) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case 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":{}}`)) + 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":"rel-timeout","stack_id":"stack-1","sequence":4,"state":"Pending"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases/rel-timeout/events/stream": + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: end\ndata: {}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases/rel-timeout": + _, _ = w.Write([]byte(`{"id":"rel-timeout","stack_id":"stack-1","state":"Released"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1": + close(stackFetchStarted) + <-r.Context().Done() + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + cfg := &config.Config{ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", ProjectName: "proj-1"} + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + cmd := newDeployCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--wait", "--timeout", "20ms", "--file", filepath.Join("..", "..", "internal", "stackfile", "testdata", "basic_image.yaml")}) + + err := cmd.Execute() + var cliErr *clierrors.CLIError + if !errors.As(err, &cliErr) { + t.Fatalf("timeout error type = %T (%v), want *CLIError", err, err) + } + if cliErr.Code != "TIMEOUT" { + t.Errorf("timeout code = %q, want TIMEOUT (error: %v)", cliErr.Code, err) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want no partial result", stdout.String()) + } + select { + case <-stackFetchStarted: + default: + t.Fatal("test did not reach final stack fetch") + } } diff --git a/cmd/stackdome/destroy.go b/cmd/stackdome/destroy.go index 9da10cf..46908fa 100644 --- a/cmd/stackdome/destroy.go +++ b/cmd/stackdome/destroy.go @@ -2,10 +2,9 @@ package main import ( "fmt" - "os" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/cmdutil" ) func newDestroyCmd() *cobra.Command { @@ -41,8 +40,12 @@ func newDestroyCmd() *cobra.Command { _ = ctx.Config.SetCurrentStack("") } - fmt.Fprintf(os.Stderr, "Stack %q deletion initiated.\n", stack.Name) - return nil + return printMutationResult(ctx, mutationResult{ + Status: "deletion_initiated", + Resource: "stack", + Name: stack.Name, + ID: stackID, + }, fmt.Sprintf("Stack %q deletion initiated.", stack.Name)) })), } diff --git a/cmd/stackdome/doctor.go b/cmd/stackdome/doctor.go new file mode 100644 index 0000000..93651ee --- /dev/null +++ b/cmd/stackdome/doctor.go @@ -0,0 +1,356 @@ +package main + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net/http" + "runtime" + "time" + + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + "github.com/Stackdome/stackdome-cli/internal/config" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" + "github.com/spf13/cobra" +) + +const ( + doctorStatusOK = "ok" + doctorStatusFail = "failed" + doctorStatusSkipped = "skipped" + doctorStatusUnknown = "unknown" +) + +type doctorCheck struct { + Status string `json:"status" yaml:"status"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` +} + +type doctorCLI struct { + doctorCheck + Version string `json:"version" yaml:"version"` + Commit string `json:"commit" yaml:"commit"` + Built string `json:"built" yaml:"built"` + Go string `json:"go" yaml:"go"` + OSArch string `json:"os_arch" yaml:"os_arch"` +} + +type doctorServer struct { + doctorCheck + URL string `json:"url" yaml:"url"` + Reachable bool `json:"reachable" yaml:"reachable"` +} + +type doctorAuth struct { + doctorCheck + Configured bool `json:"configured" yaml:"configured"` + Authenticated bool `json:"authenticated" yaml:"authenticated"` + User string `json:"user,omitempty" yaml:"user,omitempty"` +} + +type doctorOrganization struct { + doctorCheck + ID string `json:"id,omitempty" yaml:"id,omitempty"` +} + +type doctorProject struct { + doctorCheck + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Implicit bool `json:"implicit" yaml:"implicit"` +} + +type doctorStack struct { + doctorCheck + Configured bool `json:"configured" yaml:"configured"` + ID string `json:"id,omitempty" yaml:"id,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` +} + +type doctorCompatibility struct { + doctorCheck + Detail string `json:"detail,omitempty" yaml:"detail,omitempty"` +} + +// doctorResult deliberately does not depend on a Hub metadata endpoint. The +// alpha server has no stable metadata contract yet, so compatibility remains +// explicit rather than guessing from a server response. +type doctorResult struct { + CLI doctorCLI `json:"cli" yaml:"cli"` + Server doctorServer `json:"server" yaml:"server"` + Auth doctorAuth `json:"auth" yaml:"auth"` + Organization doctorOrganization `json:"organization" yaml:"organization"` + Project doctorProject `json:"project" yaml:"project"` + Stack doctorStack `json:"stack" yaml:"stack"` + Compatibility doctorCompatibility `json:"compatibility" yaml:"compatibility"` +} + +func newDoctorCmd() *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Check CLI, connection, authentication, and current context", + RunE: cmdutil.WithContext(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, _ []string) error { + result, failed := runDoctorChecks(cmd.Context(), ctx) + if !ctx.Formatter.IsTable() { + if err := ctx.Formatter.PrintStructured(result); err != nil { + return err + } + } else { + renderDoctorTable(ctx, result) + } + if failed { + return clierrors.New("One or more doctor checks failed.") + } + return nil + }), + } +} + +func runDoctorChecks(callCtx context.Context, ctx *cmdutil.CommandContext) (doctorResult, bool) { + cfg := ctx.Config + result := doctorResult{ + CLI: doctorCLI{doctorCheck: doctorCheck{Status: doctorStatusOK}, Version: Version, Commit: GitCommit, Built: BuildDate, Go: runtime.Version(), OSArch: runtime.GOOS + "/" + runtime.GOARCH}, + Server: doctorServer{URL: cfg.ServerURL}, + Auth: doctorAuth{Configured: cfg.AccessToken != ""}, + Compatibility: doctorCompatibility{ + doctorCheck: doctorCheck{Status: doctorStatusUnknown}, + Detail: "server metadata endpoint is unavailable in this CLI version", + }, + } + + result.Server.Reachable, result.Server.Error = doctorReachable(callCtx, cfg) + if result.Server.Reachable { + result.Server.Status = doctorStatusOK + } else { + result.Server.Status = doctorStatusFail + } + + orgID := cfg.OrganizationID + projectName := cfg.ProjectName + authenticatedOrgID := "" + var scopedStack *openapi.Stack + scopedValidated := false + if ctx.Client == nil { + result.Auth.doctorCheck = doctorCheck{Status: doctorStatusFail, Error: "no credentials configured"} + } else { + user, err := ctx.Client.GetCurrentUser(callCtx) + if err != nil { + if doctorDiscoveryForbidden(err) && orgID != "" && projectName != "" { + stack, scopeErr := validateConfiguredDoctorScope(callCtx, ctx, cfg) + if scopeErr == nil { + scopedValidated = true + scopedStack = stack + result.Auth.doctorCheck = doctorCheck{Status: doctorStatusOK} + result.Auth.Authenticated = true + result.Organization.doctorCheck = doctorCheck{Status: doctorStatusOK} + result.Organization.ID = orgID + result.Project.doctorCheck = doctorCheck{Status: doctorStatusOK} + result.Project.Name = projectName + result.Project.Implicit = true + } else { + result.Auth.doctorCheck = doctorCheck{Status: doctorStatusFail, Error: doctorScopeFailure(err, scopeErr)} + } + } else { + result.Auth.doctorCheck = doctorCheck{Status: doctorStatusFail, Error: clierrors.UserMessage(err)} + } + } else { + result.Auth.doctorCheck = doctorCheck{Status: doctorStatusOK} + result.Auth.Authenticated = true + result.Auth.User = userDisplayName(user) + authenticatedOrgID = user.GetOrganisationId() + if orgID == "" { + orgID = authenticatedOrgID + } + } + } + + switch { + case scopedValidated: + // The scoped endpoint accepted this exact organization/project pair. + result.Organization.doctorCheck = doctorCheck{Status: doctorStatusOK} + result.Organization.ID = orgID + case !result.Auth.Authenticated && orgID != "": + result.Organization.doctorCheck = doctorCheck{Status: doctorStatusFail, Error: "organization could not be validated without authentication"} + result.Organization.ID = orgID + case orgID == "": + result.Organization.doctorCheck = doctorCheck{Status: doctorStatusFail, Error: "organization is not configured"} + case authenticatedOrgID == "": + result.Organization.doctorCheck = doctorCheck{Status: doctorStatusFail, Error: "authenticated user has no organization"} + result.Organization.ID = orgID + case orgID != authenticatedOrgID: + result.Organization.doctorCheck = doctorCheck{ + Status: doctorStatusFail, + Error: fmt.Sprintf("configured organization %q does not match authenticated organization %q", orgID, authenticatedOrgID), + } + result.Organization.ID = orgID + default: + result.Organization.doctorCheck = doctorCheck{Status: doctorStatusOK} + result.Organization.ID = orgID + } + + if ctx.Client != nil && result.Auth.Authenticated && authenticatedOrgID != "" { + name, err := ctx.Client.ResolveDefaultProject(callCtx, authenticatedOrgID) + if err != nil { + if doctorDiscoveryForbidden(err) && orgID != "" && projectName != "" { + stack, scopeErr := validateConfiguredDoctorScope(callCtx, ctx, cfg) + if scopeErr == nil { + scopedValidated = true + scopedStack = stack + result.Project.doctorCheck = doctorCheck{Status: doctorStatusOK} + result.Project.Name = projectName + result.Project.Implicit = true + } else { + result.Project.doctorCheck = doctorCheck{Status: doctorStatusFail, Error: doctorScopeFailure(err, scopeErr)} + result.Project.Name = projectName + } + } else { + result.Project.doctorCheck = doctorCheck{Status: doctorStatusFail, Error: clierrors.UserMessage(err)} + result.Project.Name = projectName + } + } else if projectName != "" && projectName != name { + result.Project.doctorCheck = doctorCheck{ + Status: doctorStatusFail, + Error: fmt.Sprintf("configured project %q does not match signup default %q", projectName, name), + } + result.Project.Name = projectName + } else { + projectName = name + result.Project.doctorCheck = doctorCheck{Status: doctorStatusOK} + result.Project.Name = projectName + result.Project.Implicit = true + } + } + if result.Project.Status == "" { + if !result.Auth.Authenticated && projectName != "" { + result.Project.doctorCheck = doctorCheck{Status: doctorStatusFail, Error: "default project could not be validated without authentication"} + result.Project.Name = projectName + } else if projectName == "" { + result.Project.doctorCheck = doctorCheck{Status: doctorStatusFail, Error: "default project could not be determined"} + } else { + result.Project.doctorCheck = doctorCheck{Status: doctorStatusFail, Error: "default project could not be validated"} + result.Project.Name = projectName + } + } + + result.Stack.Configured = cfg.CurrentStack != "" + if !result.Stack.Configured { + result.Stack.doctorCheck = doctorCheck{Status: doctorStatusSkipped} + } else if scopedValidated && scopedStack != nil { + result.Stack.doctorCheck = doctorCheck{Status: doctorStatusOK} + result.Stack.ID = scopedStack.GetId() + result.Stack.Name = scopedStack.GetName() + } else if ctx.Client == nil || !result.Auth.Authenticated || result.Organization.Status != doctorStatusOK || result.Project.Status != doctorStatusOK { + result.Stack.doctorCheck = doctorCheck{Status: doctorStatusFail, Error: "current stack could not be checked without an authenticated project scope"} + result.Stack.ID = cfg.CurrentStack + } else { + ctx.Client.SetOrgAndProject(orgID, projectName) + stackID := cfg.CurrentStack + if !looksLikeUUID(stackID) { + lookupCmd := &cobra.Command{} + lookupCmd.SetContext(callCtx) + id, err := resolveStackRef(ctx, lookupCmd, stackID) + if err != nil { + result.Stack.doctorCheck = doctorCheck{Status: doctorStatusFail, Error: clierrors.UserMessage(err)} + } else { + stackID = id + } + } + if result.Stack.Status == "" { + stack, err := ctx.Client.GetStack(callCtx, stackID) + if err != nil { + result.Stack.doctorCheck = doctorCheck{Status: doctorStatusFail, Error: clierrors.UserMessage(err)} + } else { + result.Stack.doctorCheck = doctorCheck{Status: doctorStatusOK} + result.Stack.ID = stack.GetId() + result.Stack.Name = stack.GetName() + } + } + } + + return result, doctorFailed(result) +} + +func doctorDiscoveryForbidden(err error) bool { + var cliErr *clierrors.CLIError + return errors.As(err, &cliErr) && cliErr.Code == "FORBIDDEN" +} + +// validateConfiguredDoctorScope mirrors RequireAuth's known-scope behavior: +// scoped tokens may be unable to discover users/projects but can still prove +// the configured scope by making a request inside that project. +func validateConfiguredDoctorScope(callCtx context.Context, ctx *cmdutil.CommandContext, cfg *config.Config) (*openapi.Stack, error) { + ctx.Client.SetOrgAndProject(cfg.OrganizationID, cfg.ProjectName) + if cfg.CurrentStack == "" { + _, err := ctx.Client.ListStacks(callCtx) + return nil, err + } + if looksLikeUUID(cfg.CurrentStack) { + return ctx.Client.GetStack(callCtx, cfg.CurrentStack) + } + + stacks, err := ctx.Client.ListStacks(callCtx) + if err != nil { + return nil, err + } + for i := range stacks { + if stacks[i].Name == cfg.CurrentStack || stacks[i].GetId() == cfg.CurrentStack { + return &stacks[i], nil + } + } + return nil, clierrors.NotFoundError("Stack", cfg.CurrentStack) +} + +func doctorScopeFailure(discoveryErr, scopeErr error) string { + return fmt.Sprintf("%s; configured scope check failed: %s", discoveryErr, scopeErr) +} + +func doctorReachable(ctx context.Context, cfg *config.Config) (bool, string) { + if cfg.ServerURL == "" { + return false, "server URL is not configured" + } + client := &http.Client{Timeout: 5 * time.Second} + if cfg.Insecure { + client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.ServerURL, nil) + if err != nil { + return false, err.Error() + } + resp, err := client.Do(req) + if err != nil { + return false, err.Error() + } + resp.Body.Close() + return true, "" +} + +func doctorFailed(result doctorResult) bool { + return result.CLI.Status == doctorStatusFail || + result.Server.Status == doctorStatusFail || + result.Auth.Status == doctorStatusFail || + result.Organization.Status == doctorStatusFail || + result.Project.Status == doctorStatusFail || + (result.Stack.Configured && result.Stack.Status == doctorStatusFail) +} + +func renderDoctorTable(ctx *cmdutil.CommandContext, result doctorResult) { + table := ctx.Formatter.NewTable("CHECK", "STATUS", "DETAIL") + table.AddRow("CLI", result.CLI.Status, doctorDetail(result.CLI.doctorCheck, result.CLI.Version)) + table.AddRow("Server", result.Server.Status, doctorDetail(result.Server.doctorCheck, result.Server.URL)) + table.AddRow("Authentication", result.Auth.Status, doctorDetail(result.Auth.doctorCheck, result.Auth.User)) + table.AddRow("Organization", result.Organization.Status, doctorDetail(result.Organization.doctorCheck, result.Organization.ID)) + table.AddRow("Default project", result.Project.Status, doctorDetail(result.Project.doctorCheck, result.Project.Name)) + if result.Stack.Configured { + table.AddRow("Current stack", result.Stack.Status, doctorDetail(result.Stack.doctorCheck, fmt.Sprintf("%s %s", result.Stack.Name, result.Stack.ID))) + } + table.AddRow("Compatibility", result.Compatibility.Status, result.Compatibility.Detail) + table.Render() +} + +func doctorDetail(check doctorCheck, fallback string) string { + if check.Error != "" { + return check.Error + } + return fallback +} diff --git a/cmd/stackdome/doctor_test.go b/cmd/stackdome/doctor_test.go new file mode 100644 index 0000000..9fc087f --- /dev/null +++ b/cmd/stackdome/doctor_test.go @@ -0,0 +1,313 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + "github.com/Stackdome/stackdome-cli/internal/config" + "github.com/Stackdome/stackdome-cli/internal/output" +) + +func TestDoctorJSONReportsEveryHealthyCheck(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/api/v1/users/current": + _, _ = w.Write([]byte(`{"id":"user-1","email":"agent@example.com","organisation_id":"org-1"}`)) + case "/api/v1/users/current/projects": + _, _ = w.Write([]byte(`{"items":[{"name":"proj-1","default_project":true}],"total":1}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/11111111-1111-1111-1111-111111111111": + _, _ = w.Write([]byte(`{"id":"11111111-1111-1111-1111-111111111111","name":"demo","spec":{}}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + result, err := executeDoctor(t, &config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "proj-1", + CurrentStack: "11111111-1111-1111-1111-111111111111", + }) + if err != nil { + t.Fatalf("doctor: %v", err) + } + if result.CLI.Version == "" || result.CLI.Status != doctorStatusOK { + t.Errorf("cli = %#v, want version and ok status", result.CLI) + } + if !result.Server.Reachable || result.Server.URL != ts.URL || result.Server.Status != doctorStatusOK { + t.Errorf("server = %#v", result.Server) + } + if !result.Auth.Authenticated || result.Auth.Status != doctorStatusOK { + t.Errorf("auth = %#v", result.Auth) + } + if result.Organization.ID != "org-1" || result.Organization.Status != doctorStatusOK { + t.Errorf("organization = %#v", result.Organization) + } + if result.Project.Name != "proj-1" || !result.Project.Implicit || result.Project.Status != doctorStatusOK { + t.Errorf("project = %#v", result.Project) + } + if result.Stack.ID != "11111111-1111-1111-1111-111111111111" || result.Stack.Status != doctorStatusOK { + t.Errorf("stack = %#v", result.Stack) + } + if result.Compatibility.Status != doctorStatusUnknown { + t.Errorf("compatibility = %#v, want unknown", result.Compatibility) + } +} + +func TestDoctorFailsConfiguredOrganizationThatDoesNotMatchAuthenticatedUser(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/api/v1/users/current": + _, _ = w.Write([]byte(`{"id":"user-1","email":"agent@example.com","organisation_id":"org-actual"}`)) + case "/api/v1/users/current/projects": + _, _ = w.Write([]byte(`{"items":[{"name":"default","default_project":true}],"total":1}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + result, err := executeDoctor(t, &config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-configured", + ProjectName: "default", + }) + if err == nil { + t.Fatal("doctor succeeded with an organization that does not match the authenticated user") + } + if result.Organization.Status != doctorStatusFail { + t.Fatalf("organization = %#v, want failed", result.Organization) + } + if result.Organization.Error == "" { + t.Error("organization mismatch omitted its failure detail") + } +} + +func TestDoctorFailsConfiguredProjectThatIsNotSignupDefault(t *testing.T) { + var projectLookups int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/api/v1/users/current": + _, _ = w.Write([]byte(`{"id":"user-1","email":"agent@example.com","organisation_id":"org-1"}`)) + case "/api/v1/users/current/projects": + projectLookups++ + _, _ = w.Write([]byte(`{"items":[{"name":"signup-default","default_project":true}],"total":1}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + result, err := executeDoctor(t, &config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "stale-project", + }) + if err == nil { + t.Fatal("doctor succeeded with a configured project that is not the signup default") + } + if projectLookups != 1 { + t.Fatalf("default project lookups = %d, want 1", projectLookups) + } + if result.Project.Status != doctorStatusFail || result.Project.Error == "" { + t.Errorf("project = %#v, want failed with detail", result.Project) + } +} + +func TestDoctorAcceptsConfiguredScopeWhenProjectDiscoveryIsForbidden(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/api/v1/users/current": + _, _ = w.Write([]byte(`{"id":"user-1","email":"agent@example.com","organisation_id":"org-1"}`)) + case "/api/v1/users/current/projects": + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"reason":"project read scope denied"}`)) + case "/api/v1/organizations/org-1/projects/configured-default/stacks": + _, _ = w.Write([]byte(`{"items":[],"total":0}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + result, err := executeDoctor(t, &config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_scoped", + OrganizationID: "org-1", + ProjectName: "configured-default", + }) + if err != nil { + t.Fatalf("doctor rejected usable configured scope: %v", err) + } + if result.Auth.Status != doctorStatusOK || result.Project.Status != doctorStatusOK { + t.Errorf("auth/project = %#v / %#v, want usable configured scope", result.Auth, result.Project) + } +} + +func TestDoctorAcceptsConfiguredScopeWhenCurrentUserDiscoveryIsForbidden(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/api/v1/users/current": + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"reason":"user discovery scope denied"}`)) + case "/api/v1/organizations/org-1/projects/configured-default/stacks/11111111-1111-1111-1111-111111111111": + _, _ = w.Write([]byte(`{"id":"11111111-1111-1111-1111-111111111111","name":"demo","spec":{}}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + result, err := executeDoctor(t, &config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_scoped", + OrganizationID: "org-1", + ProjectName: "configured-default", + CurrentStack: "11111111-1111-1111-1111-111111111111", + }) + if err != nil { + t.Fatalf("doctor rejected usable stack-scoped token: %v", err) + } + if result.Auth.Status != doctorStatusOK || !result.Auth.Authenticated { + t.Errorf("auth = %#v, want ok scoped validation", result.Auth) + } + if result.Organization.Status != doctorStatusOK || result.Project.Status != doctorStatusOK { + t.Errorf("scope = org %#v project %#v, want ok", result.Organization, result.Project) + } + if result.Stack.Status != doctorStatusOK || result.Stack.Name != "demo" { + t.Errorf("stack = %#v, want validated current stack", result.Stack) + } +} + +func TestDoctorReportsFailureWhenDiscoveryAndConfiguredScopeAreForbidden(t *testing.T) { + var scopeChecks int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/api/v1/users/current": + _, _ = w.Write([]byte(`{"id":"user-1","email":"agent@example.com","organisation_id":"org-1"}`)) + case "/api/v1/users/current/projects": + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"reason":"project read scope denied"}`)) + case "/api/v1/organizations/org-1/projects/configured-default/stacks": + scopeChecks++ + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"reason":"configured scope denied"}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + result, err := executeDoctor(t, &config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_scoped", + OrganizationID: "org-1", + ProjectName: "configured-default", + }) + if err == nil { + t.Fatal("doctor succeeded when discovery and configured scope were forbidden") + } + if scopeChecks != 1 { + t.Errorf("configured scope checks = %d, want 1", scopeChecks) + } + if result.Project.Status != doctorStatusFail || result.Project.Error == "" { + t.Errorf("project = %#v, want visible scope failure", result.Project) + } + if !strings.Contains(result.Project.Error, "configured scope denied") { + t.Errorf("project error = %q, want scoped API detail", result.Project.Error) + } +} + +func TestDoctorTableRendersFailureDetail(t *testing.T) { + ctx := &cmdutil.CommandContext{Formatter: output.NewFormatter(output.FormatTable)} + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + + renderDoctorTable(ctx, doctorResult{ + CLI: doctorCLI{doctorCheck: doctorCheck{Status: doctorStatusOK}, Version: "dev"}, + Server: doctorServer{doctorCheck: doctorCheck{Status: doctorStatusFail, Error: "dial refused"}}, + Auth: doctorAuth{doctorCheck: doctorCheck{Status: doctorStatusFail, Error: "token expired"}}, + Organization: doctorOrganization{doctorCheck: doctorCheck{Status: doctorStatusFail, Error: "organization mismatch"}}, + Project: doctorProject{doctorCheck: doctorCheck{Status: doctorStatusFail, Error: "project scope denied"}}, + Stack: doctorStack{doctorCheck: doctorCheck{Status: doctorStatusSkipped}}, + Compatibility: doctorCompatibility{doctorCheck: doctorCheck{Status: doctorStatusUnknown}, Detail: "metadata unavailable"}, + }) + + for _, detail := range []string{"dial refused", "token expired", "organization mismatch", "project scope denied"} { + if !bytes.Contains(stdout.Bytes(), []byte(detail)) { + t.Errorf("table omitted %q:\n%s", detail, stdout.String()) + } + } +} + +func TestDoctorReturnsErrorAfterReportingFailingChecks(t *testing.T) { + result, err := executeDoctor(t, &config.Config{ServerURL: "http://127.0.0.1:1"}) + if err == nil { + t.Fatal("doctor succeeded despite unreachable server and missing credentials") + } + if result.Server.Status != doctorStatusFail { + t.Errorf("server = %#v, want failed reachability", result.Server) + } + if result.Auth.Status != doctorStatusFail { + t.Errorf("auth = %#v, want failed authentication", result.Auth) + } + if result.Organization.Status != doctorStatusFail || result.Project.Status != doctorStatusFail { + t.Errorf("scope checks = org %#v project %#v, want failures", result.Organization, result.Project) + } + if result.Compatibility.Status != doctorStatusUnknown { + t.Errorf("compatibility = %#v, want unknown", result.Compatibility) + } +} + +func executeDoctor(t *testing.T, cfg *config.Config) (doctorResult, error) { + t.Helper() + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + + cmd := newDoctorCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + err := cmd.Execute() + + var result doctorResult + if decodeErr := json.Unmarshal(stdout.Bytes(), &result); decodeErr != nil { + t.Fatalf("doctor JSON: %v\nstdout: %s", decodeErr, stdout.String()) + } + return result, err +} diff --git a/cmd/stackdome/helpers.go b/cmd/stackdome/helpers.go index 64a613c..977c457 100644 --- a/cmd/stackdome/helpers.go +++ b/cmd/stackdome/helpers.go @@ -1,13 +1,39 @@ package main import ( + "fmt" + "os" "strings" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/cmdutil" - clierrors "github.com/stackdome/cli/internal/errors" ) +type mutationResult struct { + Status string `json:"status" yaml:"status"` + Resource string `json:"resource" yaml:"resource"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + ID string `json:"id,omitempty" yaml:"id,omitempty"` +} + +func printMutationResult(ctx *cmdutil.CommandContext, result mutationResult, tableMessage string) error { + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(result) + } + fmt.Fprintln(os.Stderr, tableMessage) + return nil +} + +func redactSecrets(message string, secrets ...string) string { + for _, secret := range secrets { + if secret != "" { + message = strings.ReplaceAll(message, secret, "[REDACTED]") + } + } + return message +} + func resolveStackID(ctx *cmdutil.CommandContext, cmd *cobra.Command, flagStack string) (string, error) { if flagStack != "" { return resolveStackRef(ctx, cmd, flagStack) @@ -107,6 +133,11 @@ func resolveBuildID(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID, ar } func resolveReleaseID(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID, arg string) (string, error) { + // A full ID is already unambiguous and may refer to a historical release + // outside the first list page. Prefixes still use the list for expansion. + if looksLikeUUID(arg) { + return arg, nil + } releases, err := ctx.Client.ListReleases(cmd.Context(), stackID) if err != nil { return "", err diff --git a/cmd/stackdome/helpers_test.go b/cmd/stackdome/helpers_test.go index db54d15..ad03cf8 100644 --- a/cmd/stackdome/helpers_test.go +++ b/cmd/stackdome/helpers_test.go @@ -1,9 +1,15 @@ package main import ( + "context" + "net/http" + "net/http/httptest" "testing" - clierrors "github.com/stackdome/cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/client" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/spf13/cobra" ) func TestResolveIDPrefix(t *testing.T) { @@ -60,3 +66,30 @@ func TestLooksLikeUUID(t *testing.T) { } } } + +func TestResolveReleaseIDFullUUIDSkipsReleaseList(t *testing.T) { + requests := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[]}`)) + })) + defer ts.Close() + + c := client.New(ts.URL, client.WithTokens("access", ""), client.WithOrgAndProject("org-1", "proj-1")) + ctx := &cmdutil.CommandContext{Client: c} + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + fullID := "22222222-2222-2222-2222-222222222222" + + got, err := resolveReleaseID(ctx, cmd, "11111111-1111-1111-1111-111111111111", fullID) + if err != nil { + t.Fatalf("resolve full release UUID: %v", err) + } + if got != fullID { + t.Errorf("release ID = %q, want %q", got, fullID) + } + if requests != 0 { + t.Errorf("release list requests = %d, want 0", requests) + } +} diff --git a/cmd/stackdome/init.go b/cmd/stackdome/init.go index 8b35899..697246d 100644 --- a/cmd/stackdome/init.go +++ b/cmd/stackdome/init.go @@ -4,59 +4,24 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/stackfile" "github.com/spf13/cobra" - clierrors "github.com/stackdome/cli/internal/errors" - "github.com/stackdome/cli/internal/output" - "github.com/stackdome/cli/internal/stackfile" "gopkg.in/yaml.v3" ) -const stackfileTemplate = `name: {{NAME}} - -resources: - web: - image: nginx:latest - ports: - - name: http - port: 8080 - public: true - subdomain: web - env: - APP_ENV: "production" - PUBLIC_URL: "{{ self.public_url }}" - DB_HOST: "{{ db.host }}" - DB_URL: "postgres://{{ db.host }}:{{ db.port }}/mydb" - REDIS_URL: "redis://{{ redis.host }}:6379" - # secrets: - # my-secret: - # API_KEY: api_key - depends_on: [db, redis] - - db: - image: postgres:16 - ports: - - name: postgres - port: 5432 - env: - POSTGRES_DB: mydb - POSTGRES_USER: app - POSTGRES_PASSWORD: changeme - volumes: - - name: db-data - path: /var/lib/postgresql/data - - redis: - image: redis:7-alpine - ports: - - name: redis - port: 6379 - -volumes: - db-data: - size: 5Gi -` +type initResult struct { + Path string `json:"path"` + Source string `json:"source"` + Resources []string `json:"resources"` + Volumes []string `json:"volumes"` + Warnings []string `json:"warnings"` + Valid bool `json:"valid"` +} func newInitCmd() *cobra.Command { var ( @@ -71,11 +36,9 @@ func newInitCmd() *cobra.Command { Long: `Scaffold a new stackfile.yaml for your project. If a docker-compose.yaml (or compose.yaml) is found in the current directory, -it will be converted to a stackfile automatically. Use -f to specify a -compose file explicitly. - -If no compose file is found, a starter template is generated.`, - RunE: func(cmd *cobra.Command, args []string) error { +it will be converted to a Stackfile automatically. Use -f to specify a compose +file explicitly. If no compose file is found, a minimal nginx Stackfile is generated.`, + RunE: cmdutil.WithContext(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { name := flagName if name == "" { dir, err := os.Getwd() @@ -85,7 +48,7 @@ If no compose file is found, a starter template is generated.`, name = filepath.Base(dir) } - outPath := "stackfile.yaml" + const outPath = "stackfile.yaml" if !flagForce { if _, err := os.Stat(outPath); err == nil { return clierrors.ValidationError(fmt.Sprintf("%s already exists (use --force to overwrite)", outPath)) @@ -94,69 +57,68 @@ If no compose file is found, a starter template is generated.`, composePath := flagFile if composePath == "" { - dir, _ := os.Getwd() + dir, err := os.Getwd() + if err != nil { + return clierrors.Wrap(err, "Failed to get current directory") + } composePath = stackfile.FindComposeFile(dir) } - var content []byte + var ( + sf *stackfile.Stackfile + warnings []string + source = "template" + err error + ) if composePath != "" { - sf, envFiles, err := stackfile.FromCompose(composePath, name) + var composeWarnings stackfile.ComposeWarnings + sf, composeWarnings, err = stackfile.FromCompose(composePath, name) if err != nil { return clierrors.Wrap(err, "Failed to convert compose file") } - out, err := yaml.Marshal(sf) - if err != nil { - return clierrors.Wrap(err, "Failed to generate stackfile") - } - content = out - - if err := os.WriteFile(outPath, content, 0644); err != nil { - return clierrors.Wrap(err, "Failed to write stackfile") - } - - fmt.Fprintf(os.Stderr, "%s Converted %s → %s\n\n", - output.Green("✓"), composePath, outPath) - - resources := make([]string, 0, len(sf.Resources)) - for name := range sf.Resources { - resources = append(resources, name) - } - fmt.Fprintf(os.Stderr, " %s %s\n", output.Bold("Resources:"), strings.Join(resources, ", ")) - if len(sf.Volumes) > 0 { - volumes := make([]string, 0, len(sf.Volumes)) - for name := range sf.Volumes { - volumes = append(volumes, name) - } - fmt.Fprintf(os.Stderr, " %s %s\n", output.Bold("Volumes:"), strings.Join(volumes, ", ")) - } - fmt.Fprintln(os.Stderr) - - warnings := checkConvertedStackfile(sf, envFiles) - if len(warnings) > 0 { - for _, w := range warnings { - fmt.Fprintf(os.Stderr, " %s %s\n", output.Yellow("!"), w) - } - fmt.Fprintln(os.Stderr) + warnings = checkConvertedStackfile(sf, composeWarnings) + source = "compose" + } else { + sf = &stackfile.Stackfile{ + Name: name, + Resources: map[string]stackfile.Resource{ + "web": { + Image: "nginx:alpine", + Ports: []stackfile.PortDef{{Name: "http", Port: 80, Public: true}}, + }, + }, } + } - if err := stackfile.Validate(sf); err != nil { - fmt.Fprintf(os.Stderr, " %s Validation failed: %s\n\n", output.Red("✗"), err) - } else { - fmt.Fprintf(os.Stderr, " %s Validation passed\n\n", output.Green("✓")) - } + content, err := yaml.Marshal(sf) + if err != nil { + return clierrors.Wrap(err, "Failed to generate Stackfile") + } + if err := os.WriteFile(outPath, content, 0o644); err != nil { + return clierrors.Wrap(err, "Failed to write Stackfile") + } - fmt.Fprintf(os.Stderr, " %s\n", output.Dim("Next steps:")) - fmt.Fprintf(os.Stderr, " %s\n", output.Dim(" stackdome deploy -f "+outPath)) - } else { - content = []byte(strings.Replace(stackfileTemplate, "{{NAME}}", name, 1)) - if err := os.WriteFile(outPath, content, 0644); err != nil { - return clierrors.Wrap(err, "Failed to write stackfile") - } - fmt.Fprintf(os.Stderr, "Created %s\n", outPath) + result := initResult{ + Path: outPath, + Source: source, + Resources: sortedResourceNames(sf), + Volumes: sortedVolumeNames(sf), + Warnings: warnings, + Valid: stackfile.Validate(sf) == nil, + } + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(result) } + fmt.Fprintf(os.Stderr, "Created %s from %s.\n", outPath, source) + for _, warning := range warnings { + fmt.Fprintf(os.Stderr, "Warning: %s\n", warning) + } + if !result.Valid { + fmt.Fprintln(os.Stderr, "Warning: generated Stackfile needs manual changes before deployment.") + } return nil - }, + }), } cmd.Flags().StringVar(&flagName, "name", "", "App name (defaults to current directory name)") @@ -166,18 +128,84 @@ If no compose file is found, a starter template is generated.`, return cmd } -func checkConvertedStackfile(sf *stackfile.Stackfile, envFiles map[string]string) []string { +func sortedResourceNames(sf *stackfile.Stackfile) []string { + names := make([]string, 0, len(sf.Resources)) + for name := range sf.Resources { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func sortedVolumeNames(sf *stackfile.Stackfile) []string { + names := make([]string, 0, len(sf.Volumes)) + for name := range sf.Volumes { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func checkConvertedStackfile(sf *stackfile.Stackfile, composeWarnings stackfile.ComposeWarnings) []string { var warnings []string - for name, res := range sf.Resources { + if keys := composeWarnings.UnsupportedTopLevelKeys; len(keys) > 0 { + warnings = append(warnings, fmt.Sprintf("compose used unsupported top-level keys %s — review and recreate this behavior manually in stackfile.yaml", strings.Join(quotedStrings(keys), ", "))) + } + for _, name := range sortedVolumeNames(sf) { + if options := composeWarnings.UnsupportedVolumeOptions[name]; len(options) > 0 { + warnings = append(warnings, fmt.Sprintf("compose volume %q used unsupported options %s — only the volume name was preserved with a default size; recreate storage settings manually", name, strings.Join(quotedStrings(options), ", "))) + } + } + for _, name := range sortedResourceNames(sf) { + res := sf.Resources[name] if res.Build != nil && res.Build.Repo == "" { warnings = append(warnings, fmt.Sprintf("resource %q has a local build (no git repo) — set build.repo to a git URL", name)) } if res.Image == "" && res.Build == nil { warnings = append(warnings, fmt.Sprintf("resource %q has no image or build config", name)) } - if ref, ok := envFiles[name]; ok { - warnings = append(warnings, fmt.Sprintf("resource %q used env_file %q in compose — add `env_file: %s` under it to load those vars at deploy", name, ref, ref)) + if options := composeWarnings.UnsupportedBuildOptions[name]; len(options) > 0 { + warnings = append(warnings, fmt.Sprintf("resource %q used unsupported build options %s — only build.context and build.dockerfile were preserved; recreate build settings manually", name, strings.Join(quotedStrings(options), ", "))) + } + if forms := composeWarnings.UnsupportedCommandForms[name]; len(forms) > 0 { + warnings = append(warnings, fmt.Sprintf("resource %q used unsupported or ambiguous Compose command forms for %s — exact argument, shell, or image-default semantics cannot be preserved; the values were omitted, so use explicit non-empty YAML string lists", name, strings.Join(quotedStrings(forms), ", "))) + } + if options := composeWarnings.UnsupportedDependsOnOptions[name]; len(options) > 0 { + warnings = append(warnings, fmt.Sprintf("resource %q used unsupported depends_on options %s — dependency names were preserved; recreate ordering and health requirements manually", name, strings.Join(quotedStrings(options), ", "))) + } + if entries := composeWarnings.UnsupportedVolumeMountOptions[name]; len(entries) > 0 { + warnings = append(warnings, fmt.Sprintf("resource %q used unsupported volume mount entries or options %s — only supported named-volume source and target pairs were preserved; recreate mount behavior manually", name, strings.Join(quotedStrings(entries), ", "))) + } + if mappings := composeWarnings.UnsupportedPortMappings[name]; len(mappings) > 0 { + warnings = append(warnings, fmt.Sprintf("resource %q used port mappings %s whose host IP or published port cannot be represented exactly — container ports were preserved; constrained host-IP bindings remain private, and published host ports require Stackdome routing", name, strings.Join(quotedStrings(mappings), ", "))) + } + if entries := composeWarnings.UnsupportedPorts[name]; len(entries) > 0 { + warnings = append(warnings, fmt.Sprintf("resource %q used unsupported port entries %s — these ports were omitted; replace ranges, invalid syntax, or unsupported protocols with explicit TCP single-port mappings", name, strings.Join(quotedStrings(entries), ", "))) + } + if refs := composeWarnings.EnvFiles[name]; len(refs) > 0 { + quotedRefs := make([]string, len(refs)) + for i, ref := range refs { + quotedRefs[i] = fmt.Sprintf("%q", ref) + } + warnings = append(warnings, fmt.Sprintf("resource %q used env_file entries %s in compose — copy required non-sensitive values into env; Stackfiles reject env_file", name, strings.Join(quotedRefs, ", "))) + } + if binds := composeWarnings.UnsupportedBindMounts[name]; len(binds) > 0 { + warnings = append(warnings, fmt.Sprintf("resource %q used unsupported bind mount sources %s in compose — use a named volume instead", name, strings.Join(binds, ", "))) + } + if keys := composeWarnings.UnsupportedServiceKeys[name]; len(keys) > 0 { + warnings = append(warnings, fmt.Sprintf("resource %q used unsupported compose keys %s — review and recreate this behavior manually in stackfile.yaml", name, strings.Join(quotedStrings(keys), ", "))) + } + if variables := composeWarnings.UnresolvedEnvironment[name]; len(variables) > 0 { + warnings = append(warnings, fmt.Sprintf("resource %q has unresolved environment variables %s — set explicit non-sensitive values in env or connect a Stackdome secret; no values were imported", name, strings.Join(quotedStrings(variables), ", "))) } } return warnings } + +func quotedStrings(values []string) []string { + quoted := make([]string, len(values)) + for i, value := range values { + quoted[i] = fmt.Sprintf("%q", value) + } + return quoted +} diff --git a/cmd/stackdome/init_test.go b/cmd/stackdome/init_test.go new file mode 100644 index 0000000..2008021 --- /dev/null +++ b/cmd/stackdome/init_test.go @@ -0,0 +1,401 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + "github.com/Stackdome/stackdome-cli/internal/config" + "github.com/Stackdome/stackdome-cli/internal/output" + "github.com/Stackdome/stackdome-cli/internal/stackfile" +) + +const nestedComposeFixture = `services: + web: + image: nginx:alpine + build: + context: . + dockerfile: Containerfile + target: production + args: + MODE: release + cache_from: + - type=local + depends_on: + db: + condition: service_healthy + restart: true + required: false + volumes: + - data:/srv/data:ro + - cache:/srv/cache + ports: + - "8080:80" + - "8000-8002:9000-9002" + - not-a-port + db: + image: postgres:16 +volumes: + data: + driver: local + external: true + driver_opts: + type: none + labels: + tier: app + name: actual-data + cache: {} +` + +const commandPortComposeFixture = `services: + web: + image: nginx:alpine + command: /bin/sh -c 'echo "hello world"' + ports: + - "8081:81" + - "127.0.0.1:8080:80" + - "5353:5353/udp" +` + +func TestInitWithoutComposeCreatesMinimalValidStackfileJSON(t *testing.T) { + t.Chdir(t.TempDir()) + var stdout bytes.Buffer + ctx := cmdutil.NewCommandContext(&config.Config{}, output.FormatJSON, slog.LevelError) + ctx.Formatter.Writer = &stdout + cmd := newInitCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--name", "demo"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("init: %v", err) + } + + var result struct { + Path string `json:"path"` + Source string `json:"source"` + Resources []string `json:"resources"` + Volumes []string `json:"volumes"` + Warnings []string `json:"warnings"` + Valid bool `json:"valid"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("init output is not JSON: %v\n%s", err, stdout.String()) + } + if result.Path != "stackfile.yaml" || result.Source != "template" || !result.Valid { + t.Fatalf("unexpected init result: %+v", result) + } + if len(result.Resources) != 1 || result.Resources[0] != "web" { + t.Fatalf("resources = %v, want [web]", result.Resources) + } + if len(result.Volumes) != 0 || len(result.Warnings) != 0 { + t.Fatalf("volumes/warnings = %v/%v, want empty", result.Volumes, result.Warnings) + } + + content, err := os.ReadFile("stackfile.yaml") + if err != nil { + t.Fatal(err) + } + sf, err := stackfile.Load("stackfile.yaml") + if err != nil { + t.Fatalf("generated stackfile: %v\n%s", err, content) + } + web := sf.Resources["web"] + if len(sf.Resources) != 1 || web.Image != "nginx:alpine" || len(web.Ports) != 1 || web.Ports[0].Port != 80 || !web.Ports[0].Public || web.Ports[0].Protocol != "" { + t.Fatalf("generated resources = %+v, want one HTTP-defaultable public nginx:alpine port 80", sf.Resources) + } +} + +func TestInitComposeEnvFileReportsWarningWithoutEmbeddingIt(t *testing.T) { + dir := t.TempDir() + compose := filepath.Join(dir, "compose.yaml") + if err := os.WriteFile(compose, []byte("services:\n web:\n image: nginx:alpine\n env_file:\n - .env.base\n - path: .env.override\n required: false\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + var stdout bytes.Buffer + ctx := cmdutil.NewCommandContext(&config.Config{}, output.FormatJSON, slog.LevelError) + ctx.Formatter.Writer = &stdout + cmd := newInitCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--name", "demo"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("init: %v", err) + } + var result struct { + Warnings []string `json:"warnings"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("init output is not JSON: %v\n%s", err, stdout.String()) + } + if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0], "env_file") || !strings.Contains(result.Warnings[0], ".env.base") || !strings.Contains(result.Warnings[0], ".env.override") { + t.Fatalf("warnings = %v, want one warning naming every env_file", result.Warnings) + } + content, err := os.ReadFile(filepath.Join(dir, "stackfile.yaml")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(content), "env_file") { + t.Fatalf("generated stackfile must not embed unsupported env_file:\n%s", content) + } +} + +func TestInitWarnsForUnsupportedWindowsBindMount(t *testing.T) { + dir := t.TempDir() + compose := filepath.Join(dir, "compose.yaml") + content := "services:\n web:\n image: nginx:alpine\n volumes:\n - 'C:\\data:/data'\n" + if err := os.WriteFile(compose, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + var stdout bytes.Buffer + ctx := cmdutil.NewCommandContext(&config.Config{}, output.FormatJSON, slog.LevelError) + ctx.Formatter.Writer = &stdout + cmd := newInitCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--name", "demo"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("init: %v", err) + } + var result struct { + Warnings []string `json:"warnings"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("init output is not JSON: %v\n%s", err, stdout.String()) + } + if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0], "bind mount") || !strings.Contains(result.Warnings[0], `C:\data`) { + t.Fatalf("warnings = %v, want unsupported Windows bind mount warning", result.Warnings) + } +} + +func TestInitComposeWarningsAreDeterministicAndActionable(t *testing.T) { + dir := t.TempDir() + compose := filepath.Join(dir, "compose.yaml") + content := `services: + worker: + image: busybox:latest + environment: + - WORKER_TOKEN + web: + image: nginx:alpine + healthcheck: + test: [CMD, curl, -f, http://localhost] + deploy: + replicas: 2 + networks: [frontend] + profiles: [production] + environment: + API_TOKEN: +networks: + frontend: {} +secrets: + api-token: + external: true +` + if err := os.WriteFile(compose, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + var stdout bytes.Buffer + ctx := cmdutil.NewCommandContext(&config.Config{}, output.FormatJSON, slog.LevelError) + ctx.Formatter.Writer = &stdout + cmd := newInitCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--name", "demo"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("init: %v", err) + } + var result struct { + Warnings []string `json:"warnings"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("init output is not JSON: %v\n%s", err, stdout.String()) + } + want := []string{ + `compose used unsupported top-level keys "networks", "secrets" — review and recreate this behavior manually in stackfile.yaml`, + `resource "web" used unsupported compose keys "deploy", "healthcheck", "networks", "profiles" — review and recreate this behavior manually in stackfile.yaml`, + `resource "web" has unresolved environment variables "API_TOKEN" — set explicit non-sensitive values in env or connect a Stackdome secret; no values were imported`, + `resource "worker" has unresolved environment variables "WORKER_TOKEN" — set explicit non-sensitive values in env or connect a Stackdome secret; no values were imported`, + } + if len(result.Warnings) != len(want) { + t.Fatalf("warnings = %#v, want %#v", result.Warnings, want) + } + for i := range want { + if result.Warnings[i] != want[i] { + t.Errorf("warning %d = %q, want %q", i, result.Warnings[i], want[i]) + } + } + + generated, err := os.ReadFile("stackfile.yaml") + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(generated), "") || strings.Contains(string(generated), "API_TOKEN") || strings.Contains(string(generated), "WORKER_TOKEN") { + t.Fatalf("generated Stackfile invented unresolved environment values:\n%s", generated) + } +} + +func TestInitReportsNestedComposeWarningsInDeterministicJSONOrder(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte(nestedComposeFixture), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + var stdout bytes.Buffer + ctx := cmdutil.NewCommandContext(&config.Config{}, output.FormatJSON, slog.LevelError) + ctx.Formatter.Writer = &stdout + cmd := newInitCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--name", "demo"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("init: %v", err) + } + var result struct { + Warnings []string `json:"warnings"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("init output is not JSON: %v\n%s", err, stdout.String()) + } + want := []string{ + `compose volume "data" used unsupported options "driver", "driver_opts", "external", "labels", "name" — only the volume name was preserved with a default size; recreate storage settings manually`, + `resource "web" has a local build (no git repo) — set build.repo to a git URL`, + `resource "web" used unsupported build options "args", "cache_from", "target" — only build.context and build.dockerfile were preserved; recreate build settings manually`, + `resource "web" used unsupported depends_on options "db.condition", "db.required", "db.restart" — dependency names were preserved; recreate ordering and health requirements manually`, + `resource "web" used unsupported volume mount entries or options "data:/srv/data:ro" — only supported named-volume source and target pairs were preserved; recreate mount behavior manually`, + `resource "web" used port mappings "8080:80" whose host IP or published port cannot be represented exactly — container ports were preserved; constrained host-IP bindings remain private, and published host ports require Stackdome routing`, + `resource "web" used unsupported port entries "8000-8002:9000-9002", "not-a-port" — these ports were omitted; replace ranges, invalid syntax, or unsupported protocols with explicit TCP single-port mappings`, + } + if !reflect.DeepEqual(result.Warnings, want) { + t.Fatalf("warnings = %#v, want %#v", result.Warnings, want) + } +} + +func TestInitReportsNestedComposeWarningsInDefaultTablePath(t *testing.T) { + if os.Getenv("STACKDOME_TEST_INIT_NESTED_WARNINGS_HELPER") == "1" { + os.Exit(runWithWriters([]string{"init", "--name", "demo"}, os.Stdout, os.Stderr)) + } + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte(nestedComposeFixture), 0o644); err != nil { + t.Fatal(err) + } + cmd := exec.Command(os.Args[0], "-test.run=^TestInitReportsNestedComposeWarningsInDefaultTablePath$") + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "STACKDOME_TEST_INIT_NESTED_WARNINGS_HELPER=1", + "STACKDOME_CONFIG="+filepath.Join(dir, "config.yaml"), + ) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("table init: %v\nstderr:\n%s", err, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("table init stdout = %q, want empty", stdout.String()) + } + orderedFragments := []string{ + `compose volume "data" used unsupported options`, + `resource "web" has a local build`, + `resource "web" used unsupported build options`, + `resource "web" used unsupported depends_on options`, + `resource "web" used unsupported volume mount entries or options`, + `resource "web" used port mappings`, + `resource "web" used unsupported port entries`, + } + last := -1 + for _, fragment := range orderedFragments { + index := strings.Index(stderr.String(), fragment) + if index < 0 { + t.Fatalf("stderr omitted %q:\n%s", fragment, stderr.String()) + } + if index <= last { + t.Fatalf("stderr warning order is not deterministic:\n%s", stderr.String()) + } + last = index + } +} + +func TestInitReportsCommandAndPortSemanticsWarningsInDeterministicJSONOrder(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte(commandPortComposeFixture), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + var stdout bytes.Buffer + ctx := cmdutil.NewCommandContext(&config.Config{}, output.FormatJSON, slog.LevelError) + ctx.Formatter.Writer = &stdout + cmd := newInitCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--name", "demo"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("init: %v", err) + } + var result struct { + Warnings []string `json:"warnings"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("init output is not JSON: %v\n%s", err, stdout.String()) + } + want := []string{ + `resource "web" used unsupported or ambiguous Compose command forms for "command" — exact argument, shell, or image-default semantics cannot be preserved; the values were omitted, so use explicit non-empty YAML string lists`, + `resource "web" used port mappings "127.0.0.1:8080:80", "8081:81" whose host IP or published port cannot be represented exactly — container ports were preserved; constrained host-IP bindings remain private, and published host ports require Stackdome routing`, + `resource "web" used unsupported port entries "5353:5353/udp" — these ports were omitted; replace ranges, invalid syntax, or unsupported protocols with explicit TCP single-port mappings`, + } + if !reflect.DeepEqual(result.Warnings, want) { + t.Fatalf("warnings = %#v, want %#v", result.Warnings, want) + } +} + +func TestInitReportsCommandAndPortSemanticsWarningsInDefaultTablePath(t *testing.T) { + if os.Getenv("STACKDOME_TEST_INIT_COMMAND_PORT_WARNINGS_HELPER") == "1" { + os.Exit(runWithWriters([]string{"init", "--name", "demo"}, os.Stdout, os.Stderr)) + } + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte(commandPortComposeFixture), 0o644); err != nil { + t.Fatal(err) + } + cmd := exec.Command(os.Args[0], "-test.run=^TestInitReportsCommandAndPortSemanticsWarningsInDefaultTablePath$") + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "STACKDOME_TEST_INIT_COMMAND_PORT_WARNINGS_HELPER=1", + "STACKDOME_CONFIG="+filepath.Join(dir, "config.yaml"), + ) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("table init: %v\nstderr:\n%s", err, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("table init stdout = %q, want empty", stdout.String()) + } + commandIndex := strings.Index(stderr.String(), `resource "web" used unsupported or ambiguous Compose command forms for "command"`) + portIndex := strings.Index(stderr.String(), `resource "web" used port mappings "127.0.0.1:8080:80", "8081:81"`) + unsupportedIndex := strings.Index(stderr.String(), `resource "web" used unsupported port entries "5353:5353/udp"`) + if commandIndex < 0 || portIndex < 0 || unsupportedIndex < 0 { + t.Fatalf("stderr omitted command or port warning:\n%s", stderr.String()) + } + if commandIndex >= portIndex || portIndex >= unsupportedIndex { + t.Fatalf("stderr warning order is not deterministic:\n%s", stderr.String()) + } +} diff --git a/cmd/stackdome/login.go b/cmd/stackdome/login.go index 248ed68..cf122ed 100644 --- a/cmd/stackdome/login.go +++ b/cmd/stackdome/login.go @@ -2,18 +2,31 @@ package main import ( "bufio" + "errors" "fmt" + "net/url" "os" "strings" + "github.com/Stackdome/stackdome-cli/internal/client" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + "github.com/Stackdome/stackdome-cli/internal/config" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" serverapi "github.com/Stackdome/stackdome/pkg/api/openapi" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/client" - "github.com/stackdome/cli/internal/config" - clierrors "github.com/stackdome/cli/internal/errors" "golang.org/x/term" ) +type authenticationResult struct { + Authenticated bool `json:"authenticated" yaml:"authenticated"` + AccountCreated bool `json:"account_created,omitempty" yaml:"account_created,omitempty"` + User string `json:"user" yaml:"user"` + OrganizationID string `json:"organization_id" yaml:"organization_id"` + Project string `json:"project,omitempty" yaml:"project,omitempty"` + ServerURL string `json:"server_url" yaml:"server_url"` + AuthMethod string `json:"auth_method" yaml:"auth_method"` +} + func newLoginCmd() *cobra.Command { var ( flagURL string @@ -30,6 +43,10 @@ func newLoginCmd() *cobra.Command { if flagURL == "" { return clierrors.ValidationError("--url is required") } + serverURL, err := normalizeLoginServerURL(flagURL, flagInsecure) + if err != nil { + return err + } // Refuse to block on a prompt nobody can answer. if flagToken == "" && (flagEmail == "" || flagPassword == "") && !term.IsTerminal(int(os.Stdin.Fd())) { @@ -42,9 +59,9 @@ func newLoginCmd() *cobra.Command { } if flagToken != "" { - return loginWithToken(cmd, cfg, flagURL, flagToken, flagInsecure) + return loginWithToken(cmd, cfg, serverURL, flagToken, flagInsecure) } - return loginWithCredentials(cmd, cfg, flagURL, flagEmail, flagPassword, flagInsecure) + return loginWithCredentials(cmd, cfg, serverURL, flagEmail, flagPassword, flagInsecure) }, } @@ -52,11 +69,40 @@ func newLoginCmd() *cobra.Command { cmd.Flags().StringVar(&flagEmail, "email", "", "Email address") cmd.Flags().StringVar(&flagPassword, "password", "", "Password") cmd.Flags().StringVar(&flagToken, "token", "", "API token (skips email/password)") - cmd.Flags().BoolVar(&flagInsecure, "insecure", false, "Allow insecure HTTPS") + cmd.Flags().BoolVar(&flagInsecure, "insecure", false, "Allow HTTP or skip HTTPS certificate verification") return cmd } +func normalizeLoginServerURL(raw string, insecure bool) (string, error) { + serverURL := strings.TrimSpace(raw) + if !strings.Contains(serverURL, "://") { + serverURL = "https://" + serverURL + } + + parsed, err := url.Parse(serverURL) + if err != nil || parsed.Host == "" { + return "", clierrors.ValidationError("--url must be a valid Stackdome server URL") + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", clierrors.ValidationError("--url must not include credentials, a query string, or a fragment") + } + parsed.Scheme = strings.ToLower(parsed.Scheme) + parsed.Path = strings.TrimRight(parsed.Path, "/") + parsed.RawPath = strings.TrimRight(parsed.RawPath, "/") + switch parsed.Scheme { + case "https": + return parsed.String(), nil + case "http": + if !insecure { + return "", clierrors.ValidationError("refusing insecure HTTP server; use https:// or pass --insecure") + } + return parsed.String(), nil + default: + return "", clierrors.ValidationError("--url must use https://; pass --insecure to use http://") + } +} + func loginWithToken(cmd *cobra.Command, cfg *config.Config, serverURL, token string, insecure bool) error { c := client.New(serverURL, client.WithTokens(token, ""), @@ -65,7 +111,7 @@ func loginWithToken(cmd *cobra.Command, cfg *config.Config, serverURL, token str user, err := c.GetCurrentUser(cmd.Context()) if err != nil { - return err + return redactLoginError(err, token) } cfg.ServerURL = serverURL @@ -79,8 +125,19 @@ func loginWithToken(cmd *cobra.Command, cfg *config.Config, serverURL, token str return err } - fmt.Fprintf(os.Stderr, "Logged in as %s\n", cfg.Username) - return nil + return printAuthenticationResult(cmd, cfg, false, "api_token") +} + +func redactLoginError(err error, secrets ...string) error { + var cliErr *clierrors.CLIError + if errors.As(err, &cliErr) { + redacted := *cliErr + redacted.Message = redactSecrets(redacted.Message, secrets...) + redacted.Detail = redactSecrets(redacted.Detail, secrets...) + redacted.Cause = nil + return &redacted + } + return clierrors.New(redactSecrets(err.Error(), secrets...)) } // persistLogin writes the credential before resolving the project: a project @@ -97,7 +154,8 @@ func persistLogin(cmd *cobra.Command, c *client.Client, cfg *config.Config) erro projectName, err := c.ResolveDefaultProject(cmd.Context(), cfg.OrganizationID) if err != nil { - fmt.Fprintf(os.Stderr, "Warning: %s\n", clierrors.UserMessage(err)) + safeErr := redactLoginError(err, cfg.AccessToken, cfg.RefreshToken) + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %s\n", clierrors.UserMessage(safeErr)) return nil } @@ -131,7 +189,27 @@ func loginWithCredentials(cmd *cobra.Command, cfg *config.Config, serverURL, ema return err } - fmt.Fprintf(os.Stderr, "Logged in as %s\n", cfg.Username) + return printAuthenticationResult(cmd, cfg, false, "session") +} + +func printAuthenticationResult(cmd *cobra.Command, cfg *config.Config, accountCreated bool, authMethod string) error { + ctx := cmdutil.GetContext(cmd) + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(authenticationResult{ + Authenticated: true, + AccountCreated: accountCreated, + User: cfg.Username, + OrganizationID: cfg.OrganizationID, + Project: cfg.ProjectName, + ServerURL: cfg.ServerURL, + AuthMethod: authMethod, + }) + } + if accountCreated { + fmt.Fprintf(os.Stderr, "Account created. Logged in as %s\n", cfg.Username) + } else { + fmt.Fprintf(os.Stderr, "Logged in as %s\n", cfg.Username) + } return nil } diff --git a/cmd/stackdome/login_url_test.go b/cmd/stackdome/login_url_test.go new file mode 100644 index 0000000..48ca851 --- /dev/null +++ b/cmd/stackdome/login_url_test.go @@ -0,0 +1,158 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func TestTokenLoginSchemelessURLUsesHTTPSAndPersistsNormalizedURL(t *testing.T) { + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/users/current": + _, _ = w.Write([]byte(`{"id":"user-1","email":"agent@example.com","organisation_id":"org-1","role":"OrgAdmin"}`)) + case "/api/v1/users/current/projects": + _, _ = w.Write([]byte(`{"items":[{"id":"project-1","name":"default","default_project":true}],"total":1}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + serverWithoutScheme := strings.TrimPrefix(ts.URL, "https://") + var stdout, stderr bytes.Buffer + code := runWithWriters([]string{ + "login", "--url", serverWithoutScheme, "--token", "sdm_test", "--insecure", "-o", "json", + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String()) + } + + var got authenticationResult + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("stdout is not an authentication result: %v\n%s", err, stdout.String()) + } + if got.ServerURL != ts.URL { + t.Fatalf("server URL = %q, want normalized HTTPS URL %q", got.ServerURL, ts.URL) + } +} + +func TestTokenLoginRejectsHTTPWithoutInsecureBeforeSendingToken(t *testing.T) { + requests := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"reason":"token rejected"}`)) + })) + defer ts.Close() + + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + const token = "sdm_must_not_be_sent" + var stdout, stderr bytes.Buffer + code := runWithWriters([]string{"login", "--url", ts.URL, "--token", token, "-o", "json"}, &stdout, &stderr) + if code != 4 { + t.Fatalf("exit code = %d, want validation exit 4; stderr: %s", code, stderr.String()) + } + if requests != 0 { + t.Fatalf("HTTP requests = %d, want rejection before sending credentials", requests) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), "--insecure") { + t.Fatalf("stderr = %q, want --insecure remediation", stderr.String()) + } + if strings.Contains(stderr.String(), token) { + t.Fatalf("stderr leaked token: %s", stderr.String()) + } +} + +func TestTokenLoginDoesNotPrintTokenEchoedByServer(t *testing.T) { + const token = "sdm_secret_login_token" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"reason":"rejected ` + token + `"}`)) + })) + defer ts.Close() + + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + var stdout, stderr bytes.Buffer + code := runWithWriters([]string{ + "login", "--url", ts.URL, "--token", token, "--insecure", "-o", "json", + }, &stdout, &stderr) + if code != 4 { + t.Fatalf("login exit code = %d, want server validation exit 4", code) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if strings.Contains(stderr.String(), token) { + t.Fatalf("stderr leaked API token echoed by server: %s", stderr.String()) + } +} + +func TestTokenLoginRedactsTokenEchoedByProjectDiscovery(t *testing.T) { + const token = "sdm_secret_project_lookup_token" + 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/users/current": + _, _ = w.Write([]byte(`{"id":"user-1","email":"agent@example.com","organisation_id":"org-1"}`)) + case "/api/v1/users/current/projects": + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"reason":"project lookup rejected ` + token + `"}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + var stdout, stderr bytes.Buffer + code := runWithWriters([]string{ + "login", "--url", ts.URL, "--token", token, "--insecure", "-o", "json", + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("login exit code = %d, want persisted login success; stderr: %s", code, stderr.String()) + } + if strings.Contains(stdout.String(), token) || strings.Contains(stderr.String(), token) { + t.Fatalf("project discovery leaked API token: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "[REDACTED]") { + t.Fatalf("stderr = %q, want captured redacted project warning", stderr.String()) + } +} + +func TestNormalizeLoginServerURLRejectsOutputSensitiveComponents(t *testing.T) { + for _, raw := range []string{ + "https://user:secret@stackdome.example", + "https://stackdome.example?token=secret", + "https://stackdome.example#secret", + } { + t.Run(raw, func(t *testing.T) { + if _, err := normalizeLoginServerURL(raw, false); err == nil { + t.Fatalf("normalizeLoginServerURL(%q) succeeded, want validation error", raw) + } + }) + } +} + +func TestNormalizeLoginServerURLRemovesTrailingSlash(t *testing.T) { + got, err := normalizeLoginServerURL("https://stackdome.example/", false) + if err != nil { + t.Fatal(err) + } + if got != "https://stackdome.example" { + t.Fatalf("normalized URL = %q, want https://stackdome.example", got) + } +} diff --git a/cmd/stackdome/logout.go b/cmd/stackdome/logout.go index 23e9ce6..327a823 100644 --- a/cmd/stackdome/logout.go +++ b/cmd/stackdome/logout.go @@ -4,8 +4,8 @@ import ( "fmt" "os" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/cmdutil" ) func newLogoutCmd() *cobra.Command { @@ -16,6 +16,11 @@ func newLogoutCmd() *cobra.Command { if err := ctx.Config.Clear(); err != nil { return err } + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(struct { + LoggedOut bool `json:"logged_out" yaml:"logged_out"` + }{LoggedOut: true}) + } fmt.Fprintln(os.Stderr, "Logged out.") return nil }), diff --git a/cmd/stackdome/logs.go b/cmd/stackdome/logs.go index 6bc2699..fdf205e 100644 --- a/cmd/stackdome/logs.go +++ b/cmd/stackdome/logs.go @@ -1,15 +1,21 @@ package main import ( - "fmt" - "os" + "context" + "encoding/json" + "github.com/Stackdome/stackdome-cli/internal/client" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/output" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/client" - "github.com/stackdome/cli/internal/cmdutil" - clierrors "github.com/stackdome/cli/internal/errors" ) +type logEvent struct { + Event string `json:"event"` + Data any `json:"data"` +} + func newLogsCmd() *cobra.Command { var ( flagFollow bool @@ -23,6 +29,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 err := output.ValidateStreamingFormat(ctx.Formatter.Format); err != nil { + return err + } stackID, err := resolveStackID(ctx, cmd, flagStack) if err != nil { return err @@ -41,21 +50,26 @@ func newLogsCmd() *cobra.Command { stream, err := ctx.Client.StreamLogs(cmd.Context(), stackID, resourceName, opts) if err != nil { + if cmd.Context().Err() == context.Canceled { + return clierrors.ErrUserCanceled + } return err } defer stream.Close() - return client.ParseSSEStream(stream, func(e client.SSEEvent) error { + err = client.ParseSSEStream(stream, func(e client.SSEEvent) error { if e.Event == "error" { - fmt.Fprintf(os.Stderr, "Error: %s\n", e.Data) return clierrors.New(e.Data) } if e.IsEnd() { return nil } - fmt.Println(e.Data) - return nil + return printLogEvent(ctx.Formatter, e) }) + if cmd.Context().Err() == context.Canceled { + return clierrors.ErrUserCanceled + } + return err })), } @@ -66,3 +80,16 @@ func newLogsCmd() *cobra.Command { return cmd } + +func printLogEvent(formatter *output.Formatter, event client.SSEEvent) error { + if formatter.Format != output.FormatJSON { + formatter.Println(event.Data) + return nil + } + + var data any + if err := json.Unmarshal([]byte(event.Data), &data); err != nil { + data = event.Data + } + return formatter.PrintJSONLine(logEvent{Event: event.Event, Data: data}) +} diff --git a/cmd/stackdome/logs_test.go b/cmd/stackdome/logs_test.go new file mode 100644 index 0000000..0a8fd61 --- /dev/null +++ b/cmd/stackdome/logs_test.go @@ -0,0 +1,290 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + "github.com/Stackdome/stackdome-cli/internal/config" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/output" +) + +func TestLogsRejectsYAMLStreamOutput(t *testing.T) { + const stackID = "b02262ac-8e6e-45cd-b18e-acb5d3f97ce4" + requests := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + 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.FormatYAML, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + cmd := newLogsCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + + err := cmd.Execute() + var cliErr *clierrors.CLIError + if !errors.As(err, &cliErr) || cliErr.Code != "VALIDATION_ERROR" { + t.Fatalf("logs YAML error = %T (%v), want validation error", err, err) + } + if stdout.Len() != 0 || requests != 0 { + t.Errorf("stdout = %q, requests = %d; want rejection before streaming", stdout.String(), requests) + } +} + +// JSON log streams must preserve a JSON payload as data, rather than burying +// it in a quoted string that agents would have to decode a second time. +func TestLogsJSONWritesDecodedNDJSONEvent(t *testing.T) { + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/logs": + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: log\ndata: {\"message\":\"hello\"}\n\nevent: log\ndata: plain line\n\nevent: end\ndata: {}\n\n")) + 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"}, output.FormatJSON, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + cmd := newLogsCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--stack", "app"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("logs: %v", err) + } + + if bytes.Contains(stdout.Bytes(), []byte("\n ")) { + t.Fatalf("log stream is indented instead of compact: %q", stdout.String()) + } + var lines []struct { + Event string `json:"event"` + Data json.RawMessage `json:"data"` + } + scanner := bufio.NewScanner(bytes.NewReader(stdout.Bytes())) + for scanner.Scan() { + var line struct { + Event string `json:"event"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { + t.Fatalf("log line is not independent JSON: %v\nline: %s", err, scanner.Text()) + } + lines = append(lines, line) + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan log output: %v", err) + } + if len(lines) != 2 { + t.Fatalf("decoded %d lines, want 2: %s", len(lines), stdout.String()) + } + var first struct { + Message string `json:"message"` + } + if err := json.Unmarshal(lines[0].Data, &first); err != nil { + t.Fatalf("first data is not decoded JSON: %v", err) + } + var second string + if err := json.Unmarshal(lines[1].Data, &second); err != nil { + t.Fatalf("second data is not a JSON string: %v", err) + } + if lines[0].Event != "log" || first.Message != "hello" || lines[1].Event != "log" || second != "plain line" { + t.Errorf("lines = %#v, want decoded JSON then raw string data", lines) + } +} + +// Human log output remains the server's raw line so existing terminal and +// shell workflows are unchanged by the JSON stream support. +func TestLogsTableWritesRawData(t *testing.T) { + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/logs": + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: log\ndata: [web] hello\n\nevent: end\ndata: {}\n\n")) + 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"}, output.FormatTable, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + cmd := newLogsCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--stack", "app"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("logs: %v", err) + } + if got := stdout.String(); got != "[web] hello\n" { + t.Errorf("raw logs = %q, want raw server line", got) + } +} + +// 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) { + if os.Getenv("STACKDOME_TEST_LOG_ERROR_HELPER") == "1" { + os.Exit(runWithWriters([]string{"logs", "--stack", "app", "-o", "json"}, os.Stdout, os.Stderr)) + } + + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/logs": + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: error\ndata: runtime stream failed\n\n")) + 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) + } + + child := exec.Command(os.Args[0], "-test.run=^TestLogsJSONServerErrorIsSingleRootDocument$") + child.Env = append(os.Environ(), "STACKDOME_TEST_LOG_ERROR_HELPER=1", "STACKDOME_CONFIG="+configPath) + var stdout, stderr bytes.Buffer + child.Stdout = &stdout + child.Stderr = &stderr + if err := child.Run(); err == nil { + t.Fatal("logs process succeeded, want server stream failure") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + var got struct { + Error string `json:"error"` + ExitCode int `json:"exit_code"` + } + if err := json.Unmarshal(stderr.Bytes(), &got); err != nil { + t.Fatalf("stderr is not one JSON document: %v\nstderr: %s", err, stderr.String()) + } + if got.Error != "runtime stream failed" || got.ExitCode == 0 { + t.Errorf("error document = %#v, want runtime stream failure", got) + } +} + +// Cancelling an active stream is an interruption, not a stream failure. The +// command must preserve the cancellation sentinel for the root exit boundary. +func TestLogsCancellationAfterStreamStartsIsUserCancellation(t *testing.T) { + const stackID = "b02262ac-8e6e-45cd-b18e-acb5d3f97ce4" + started := make(chan struct{}) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + close(started) + <-r.Context().Done() + })) + 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 + parent, cancel := context.WithCancel(context.Background()) + defer cancel() + cmd := newLogsCmd() + cmd.SetContext(parent) + cmdutil.SetContext(cmd, ctx) + + errCh := make(chan error, 1) + go func() { errCh <- cmd.Execute() }() + select { + case <-started: + cancel() + case <-time.After(time.Second): + t.Fatal("log stream did not start") + } + select { + case err := <-errCh: + if err != clierrors.ErrUserCanceled { + t.Fatalf("cancellation error = %v, want ErrUserCanceled", err) + } + case <-time.After(time.Second): + t.Fatal("logs command did not return after cancellation") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } +} + +// Exercise the real root boundary without modifying root.go: after a stream +// has started, an interrupt must produce the conventional 130 JSON error. +func TestLogsRootCancellationWritesJSONErrorAndNoStdout(t *testing.T) { + const stackID = "b02262ac-8e6e-45cd-b18e-acb5d3f97ce4" + parent, cancel := context.WithCancel(context.Background()) + defer cancel() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/organizations/org-1/projects/proj-1/stacks/"+stackID+"/logs" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + cancel() + <-r.Context().Done() + })) + defer ts.Close() + + configPath := filepath.Join(t.TempDir(), "config.json") + t.Setenv("STACKDOME_CONFIG", configPath) + if err := (&config.Config{ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", ProjectName: "proj-1", CurrentStack: stackID}).Save(); err != nil { + t.Fatalf("save config: %v", err) + } + var stdout, stderr bytes.Buffer + if code := runWithContext(parent, []string{"logs", "-o", "json"}, &stdout, &stderr); code != clierrors.ExitUserCanceled { + t.Fatalf("exit code = %d, want %d; stderr=%s", code, clierrors.ExitUserCanceled, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + var result struct { + Error string `json:"error"` + ExitCode int `json:"exit_code"` + } + if err := json.Unmarshal(stderr.Bytes(), &result); err != nil { + t.Fatalf("stderr is not one JSON document: %v\nstderr: %s", err, stderr.String()) + } + if result.Error != "Aborted." || result.ExitCode != clierrors.ExitUserCanceled { + t.Errorf("error document = %#v, want cancellation", result) + } +} diff --git a/cmd/stackdome/open.go b/cmd/stackdome/open.go index d2e3701..830626d 100644 --- a/cmd/stackdome/open.go +++ b/cmd/stackdome/open.go @@ -7,10 +7,10 @@ import ( "runtime" "strings" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/output" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/cmdutil" - clierrors "github.com/stackdome/cli/internal/errors" - "github.com/stackdome/cli/internal/output" ) func newOpenCmd() *cobra.Command { @@ -23,7 +23,7 @@ func newOpenCmd() *cobra.Command { With -o json|yaml no browser is launched: the public URLs are printed to stdout as {"target": ..., "urls": [...]}.`, - Args: cobra.MaximumNArgs(1), + Args: cobra.MaximumNArgs(1), RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { stackID, err := resolveStackID(ctx, cmd, flagStack) if err != nil { diff --git a/cmd/stackdome/release.go b/cmd/stackdome/release.go index a2c9781..4484cf2 100644 --- a/cmd/stackdome/release.go +++ b/cmd/stackdome/release.go @@ -8,12 +8,12 @@ import ( "strings" "time" + "github.com/Stackdome/stackdome-cli/internal/client" + "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" - "github.com/stackdome/cli/internal/client" - "github.com/stackdome/cli/internal/cmdutil" - clierrors "github.com/stackdome/cli/internal/errors" - "github.com/stackdome/cli/internal/output" ) func newReleaseCmd() *cobra.Command { @@ -25,6 +25,7 @@ func newReleaseCmd() *cobra.Command { cmd.AddCommand(newReleaseListCmd()) cmd.AddCommand(newReleaseInfoCmd()) cmd.AddCommand(newReleaseCancelCmd()) + cmd.AddCommand(newReleaseRollbackCmd()) cmd.AddCommand(newReleaseEventsCmd()) return cmd } @@ -130,12 +131,90 @@ func newReleaseCancelCmd() *cobra.Command { if err := ctx.Client.CancelRelease(cmd.Context(), stackID, releaseID); err != nil { return err } - fmt.Fprintf(os.Stderr, "Release %s cancelled.\n", releaseID) + return printMutationResult(ctx, mutationResult{ + Status: "cancelled", + Resource: "release", + ID: releaseID, + }, fmt.Sprintf("Release %s cancelled.", releaseID)) + })), + } + + cmd.Flags().StringVarP(&flagStack, "stack", "s", "", "Stack name (overrides current context)") + return cmd +} + +// rollbackResult keeps the created release available to automation and adds +// live status only when --wait has observed its terminal state. +type rollbackResult struct { + Release any `json:"release" yaml:"release"` + LiveStatus *openapi.ReleaseLiveStatus `json:"live_status,omitempty" yaml:"live_status,omitempty"` +} + +func newReleaseRollbackCmd() *cobra.Command { + var ( + flagStack string + flagWait bool + flagTimeout time.Duration + ) + + cmd := &cobra.Command{ + Use: "rollback ", + Short: "Create a release from a historical release", + Long: "Create a new release from a historical release. --wait follows it for up to 10 minutes by default.", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + stackID, err := resolveStackID(ctx, cmd, flagStack) + if err != nil { + return err + } + fromReleaseID, err := resolveReleaseID(ctx, cmd, stackID, args[0]) + if err != nil { + return err + } + + release, err := ctx.Client.RollbackRelease(cmd.Context(), stackID, fromReleaseID) + if err != nil { + return err + } + if !flagWait { + if !ctx.Formatter.IsTable() { + 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()) + 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 err := waitCommandError(cmd.Context(), waitCtx, nil); err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + if err := ctx.Formatter.PrintStructured(rollbackResult{Release: final, LiveStatus: live}); err != nil { + return err + } + } else { + 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 rollback release to finish") + cmd.Flags().DurationVar(&flagTimeout, "timeout", defaultWaitTimeout, "Maximum time to wait for the rollback release") return cmd } @@ -149,6 +228,12 @@ func newReleaseEventsCmd() *cobra.Command { Use: "events ", Short: "Show release events", 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 nil + }), RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { stackID, err := resolveStackID(ctx, cmd, flagStack) if err != nil { @@ -163,6 +248,9 @@ func newReleaseEventsCmd() *cobra.Command { if flagFollow { events, err := ctx.Client.StreamReleaseEvents(cmd.Context(), stackID, releaseID, 0) if err != nil { + if cmd.Context().Err() != nil { + return clierrors.ErrUserCanceled + } return err } var streamErr error @@ -182,6 +270,9 @@ func newReleaseEventsCmd() *cobra.Command { fmt.Fprintf(os.Stdout, "{\"event\":%q,\"data\":%s}\n", e.Event, eventDataJSON(e.Data)) } } + if cmd.Context().Err() != nil { + return clierrors.ErrUserCanceled + } return streamErr } diff --git a/cmd/stackdome/release_test.go b/cmd/stackdome/release_test.go index 59b3adb..13990f3 100644 --- a/cmd/stackdome/release_test.go +++ b/cmd/stackdome/release_test.go @@ -1,10 +1,743 @@ package main import ( + "bytes" + "context" "encoding/json" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "strings" "testing" + "time" + + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + "github.com/Stackdome/stackdome-cli/internal/config" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/output" ) +const rollbackTestStackID = "11111111-1111-1111-1111-111111111111" + +func TestReleaseRollbackCreatesReleaseFromHistoricalRelease(t *testing.T) { + var rollbackBody struct { + FromReleaseID string `json:"from_release_id"` + } + 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/11111111-1111-1111-1111-111111111111/releases": + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`{"items":[{"id":"release-previous"}]}`)) + return + } + if r.Method == http.MethodPost { + if err := json.NewDecoder(r.Body).Decode(&rollbackBody); err != nil { + t.Fatalf("decode rollback request: %v", err) + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"release-rollback","stack_id":"11111111-1111-1111-1111-111111111111","sequence":7,"state":"Pending"}`)) + return + } + } + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer ts.Close() + + cfg := &config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "proj-1", + CurrentStack: "11111111-1111-1111-1111-111111111111", + } + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + + cmd := newReleaseCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"rollback", "release-previous"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("release rollback: %v", err) + } + + if rollbackBody.FromReleaseID != "release-previous" { + t.Errorf("from_release_id = %q, want %q", rollbackBody.FromReleaseID, "release-previous") + } + var result struct { + Release struct { + ID string `json:"id"` + } `json:"release"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("rollback JSON: %v\nstdout: %s", err, stdout.String()) + } + if result.Release.ID != "release-rollback" { + t.Errorf("release.id = %q, want %q", result.Release.ID, "release-rollback") + } + +} + +func TestReleaseRollbackWaitJSONIncludesTerminalReleaseAndLiveStatus(t *testing.T) { + var releaseReads int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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/"+rollbackTestStackID+"/releases": + _, _ = w.Write([]byte(`{"items":[{"id":"release-previous"}]}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","sequence":7,"state":"Pending"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases/release-rollback/events/stream": + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: end\ndata: {}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases/release-rollback": + releaseReads++ + if releaseReads == 1 { + _, _ = w.Write([]byte(`{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","state":"Released"}`)) + return + } + _, _ = w.Write([]byte(`{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","state":"Released","live_status":{"health":"ok"}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID: + _, _ = w.Write([]byte(`{"id":"` + rollbackTestStackID + `","name":"demo","spec":{},"converged_release":{"id":"release-rollback"}}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + ctx, stdout := rollbackCommandContext(ts.URL) + cmd := newReleaseCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"rollback", "release-previous", "--wait"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("release rollback --wait: %v", err) + } + + var result struct { + Release struct { + ID string `json:"id"` + State string `json:"state"` + } `json:"release"` + LiveStatus struct { + Health string `json:"health"` + } `json:"live_status"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("rollback JSON: %v\nstdout: %s", err, stdout.String()) + } + if result.Release.ID != "release-rollback" || result.Release.State != "Released" { + t.Errorf("release = %#v, want terminal rollback release", result.Release) + } + if result.LiveStatus.Health != "ok" { + t.Errorf("live_status.health = %q, want ok", result.LiveStatus.Health) + } +} + +func TestReleaseRollbackWaitContinuesAfterStreamEndsWhileReleaseIsPending(t *testing.T) { + var releaseReads int + var streamReads int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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/"+rollbackTestStackID+"/releases": + _, _ = w.Write([]byte(`{"items":[{"id":"release-previous"}]}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","sequence":7,"state":"Pending"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases/release-rollback/events/stream": + streamReads++ + wantAfter := "0" + sequence := "3" + if streamReads == 2 { + wantAfter = "3" + sequence = "4" + } + if got := r.URL.Query().Get("after_sequence"); got != wantAfter { + t.Errorf("event stream %d after_sequence = %q, want %q", streamReads, got, wantAfter) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: message\ndata: {\"sequence\":" + sequence + ",\"message\":\"progress\"}\n\nevent: end\ndata: {}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases/release-rollback": + releaseReads++ + switch releaseReads { + case 1: + _, _ = w.Write([]byte(`{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","state":"Pending"}`)) + case 2: + _, _ = w.Write([]byte(`{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","state":"Released"}`)) + default: + _, _ = w.Write([]byte(`{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","state":"Released","live_status":{"health":"ok"}}`)) + } + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID: + _, _ = w.Write([]byte(`{"id":"` + rollbackTestStackID + `","name":"demo","spec":{},"converged_release":{"id":"release-rollback"}}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + ctx, stdout := rollbackCommandContext(ts.URL) + cmd := newReleaseCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"rollback", "release-previous", "--wait"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("release rollback --wait: %v", err) + } + + if streamReads != 2 { + t.Errorf("event stream requests = %d, want 2", streamReads) + } + if releaseReads != 3 { + t.Errorf("release reads = %d, want 3", releaseReads) + } + var result struct { + Release struct { + State string `json:"state"` + } `json:"release"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("rollback JSON: %v\nstdout: %s", err, stdout.String()) + } + if result.Release.State != "Released" { + t.Errorf("release state = %q, want Released", result.Release.State) + } +} + +// A waited rollback has the same observation contract as a waited deploy: the +// newly-created release must be the one serving, be healthy, and expose every +// declared public port. These cases would all have been reported as success +// when rollback printed the post-release fetch without verifying it. +func TestReleaseRollbackWaitVerifiesDeploymentObservation(t *testing.T) { + tests := []struct { + name string + stackResponse string + liveResponse string + wantErr bool + }{ + { + name: "converged release is not the new rollback release", + stackResponse: `{"id":"` + rollbackTestStackID + `","name":"demo","spec":{},"converged_release":{"id":"release-other"}}`, + liveResponse: `{"id":"release-other","stack_id":"` + rollbackTestStackID + `","state":"Released","live_status":{"health":"ok"}}`, + wantErr: true, + }, + { + name: "runtime health is degraded", + stackResponse: `{"id":"` + rollbackTestStackID + `","name":"demo","spec":{},"converged_release":{"id":"release-rollback"}}`, + liveResponse: `{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","state":"Released","live_status":{"health":"degraded"}}`, + wantErr: true, + }, + { + name: "declared public port has no URL", + stackResponse: `{"id":"` + rollbackTestStackID + `","name":"demo","spec":{"stack_resources":[{"name":"web","ports":[{"name":"http","number":80,"exposed_to_public":true}]}]},"converged_release":{"id":"release-rollback"}}`, + liveResponse: `{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","state":"Released","live_status":{"health":"ok","resources":{"web":{}}}}`, + wantErr: true, + }, + { + name: "private port does not require a URL", + stackResponse: `{"id":"` + rollbackTestStackID + `","name":"demo","spec":{"stack_resources":[{"name":"web","ports":[{"name":"http","number":80,"exposed_to_public":false}]}]},"converged_release":{"id":"release-rollback"}}`, + liveResponse: `{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","state":"Released","live_status":{"health":"ok","resources":{"web":{}}}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := rollbackObservationServer(t, tt.stackResponse, tt.liveResponse) + defer ts.Close() + + ctx, stdout := rollbackCommandContext(ts.URL) + cmd := newReleaseCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"rollback", "release-previous", "--wait"}) + err := cmd.Execute() + + if tt.wantErr { + if err == nil { + t.Fatal("release rollback --wait returned nil, want verification failure") + } + if clierrors.ExitCodeFrom(err) != clierrors.ExitGeneral { + t.Errorf("exit code = %d (%v), want general verification failure", clierrors.ExitCodeFrom(err), err) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty on verification failure", stdout.String()) + } + return + } + + if err != nil { + t.Fatalf("release rollback --wait: %v", err) + } + if stdout.Len() == 0 { + t.Fatal("stdout is empty, want successful rollback result") + } + }) + } +} + +func TestReleaseRollbackWaitFailureReturnsErrorWithEmptyStdout(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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/"+rollbackTestStackID+"/releases": + _, _ = w.Write([]byte(`{"items":[{"id":"release-previous"}]}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","sequence":7,"state":"Pending"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases/release-rollback/events/stream": + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: end\ndata: {}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases/release-rollback": + _, _ = w.Write([]byte(`{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","state":"Failed","message":"image pull failed"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID: + _, _ = w.Write([]byte(`{"id":"` + rollbackTestStackID + `","name":"demo","spec":{},"latest_release":{"id":"release-rollback"}}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + ctx, stdout := rollbackCommandContext(ts.URL) + cmd := newReleaseCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"rollback", "release-previous", "--wait"}) + err := cmd.Execute() + if err == nil { + t.Fatal("failed rollback returned nil") + } + if !strings.Contains(err.Error(), "image pull failed") { + t.Errorf("rollback error = %v, want release failure detail", err) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty on failed rollback", stdout.String()) + } +} + +func TestReleaseRollbackWaitTimeoutIsNotUserCancellation(t *testing.T) { + ts := rollbackBlockingServer(t, nil) + defer ts.Close() + + ctx, stdout := rollbackCommandContext(ts.URL) + cmd := newReleaseCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"rollback", "release-previous", "--wait", "--timeout", "20ms"}) + + started := time.Now() + err := cmd.Execute() + if err == nil { + t.Fatal("release rollback --wait returned nil, want timeout error") + } + if err == clierrors.ErrUserCanceled { + t.Fatalf("deadline reported as user cancellation: %v", err) + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("rollback timeout took %s, want under 1s", elapsed) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want no partial result", stdout.String()) + } +} + +func TestReleaseRollbackWaitTimeoutBeforeStreamHeadersUsesTimeoutContract(t *testing.T) { + streamStarted := make(chan struct{}) + ts := rollbackUnflushedStreamServer(t, streamStarted) + defer ts.Close() + + ctx, stdout := rollbackCommandContext(ts.URL) + cmd := newReleaseCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"rollback", "release-previous", "--wait", "--timeout", "20ms"}) + + err := cmd.Execute() + var cliErr *clierrors.CLIError + if !errors.As(err, &cliErr) { + t.Fatalf("timeout error type = %T (%v), want *CLIError", err, err) + } + if cliErr.Code != "TIMEOUT" { + t.Fatalf("timeout code = %q, want TIMEOUT (error: %v)", cliErr.Code, err) + } + if cliErr.Message != "Timed out waiting for the release to finish." { + t.Errorf("timeout message = %q", cliErr.Message) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want no partial result", stdout.String()) + } + select { + case <-streamStarted: + default: + t.Fatal("test did not reach the unflushed event stream") + } +} + +func TestReleaseRollbackWaitParentCancellationIsUserCancellation(t *testing.T) { + streamStarted := make(chan struct{}) + ts := rollbackBlockingServer(t, streamStarted) + defer ts.Close() + + ctx, stdout := rollbackCommandContext(ts.URL) + parent, cancel := context.WithCancel(context.Background()) + cmd := newReleaseCmd() + cmd.SetContext(parent) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"rollback", "release-previous", "--wait", "--timeout", "1m"}) + + errCh := make(chan error, 1) + go func() { errCh <- cmd.Execute() }() + select { + case <-streamStarted: + cancel() + case <-time.After(time.Second): + t.Fatal("rollback event stream did not start") + } + + select { + case err := <-errCh: + if err != clierrors.ErrUserCanceled { + t.Fatalf("cancellation error = %v, want ErrUserCanceled", err) + } + case <-time.After(time.Second): + t.Fatal("rollback command did not return after cancellation") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want no partial result", stdout.String()) + } +} + +// Parent cancellation remains an interrupt even after the terminal release has +// been observed and the command is fetching the converged release's live +// status. No structured rollback result may be emitted first. +func TestReleaseRollbackWaitParentCancellationDuringLiveStatusIsUserCancellation(t *testing.T) { + liveFetchStarted := make(chan struct{}) + var releaseReads int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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/"+rollbackTestStackID+"/releases": + _, _ = w.Write([]byte(`{"items":[{"id":"release-previous"}]}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","sequence":7,"state":"Pending"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases/release-rollback/events/stream": + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: end\ndata: {}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases/release-rollback": + releaseReads++ + if releaseReads == 1 { + _, _ = w.Write([]byte(`{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","state":"Released"}`)) + return + } + close(liveFetchStarted) + <-r.Context().Done() + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID: + _, _ = w.Write([]byte(`{"id":"` + rollbackTestStackID + `","name":"demo","spec":{},"converged_release":{"id":"release-rollback"}}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + ctx, stdout := rollbackCommandContext(ts.URL) + parent, cancel := context.WithCancel(context.Background()) + defer cancel() + cmd := newReleaseCmd() + cmd.SetContext(parent) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"rollback", "release-previous", "--wait", "--timeout", "1m"}) + + errCh := make(chan error, 1) + go func() { errCh <- cmd.Execute() }() + select { + case <-liveFetchStarted: + cancel() + case <-time.After(time.Second): + t.Fatal("rollback live-status fetch did not start") + } + select { + case err := <-errCh: + if err != clierrors.ErrUserCanceled { + t.Fatalf("cancellation error = %v, want ErrUserCanceled", err) + } + case <-time.After(time.Second): + t.Fatal("rollback command did not return after cancellation") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want no partial result", stdout.String()) + } +} + +func TestReleaseEventsFollowYAMLFailsValidationBeforeAPIRequest(t *testing.T) { + requests := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + cfg := &config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "proj-1", + CurrentStack: rollbackTestStackID, + } + ctx := cmdutil.NewCommandContext(cfg, output.FormatYAML, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + + cmd := newReleaseCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"events", "22222222-2222-2222-2222-222222222222", "--follow"}) + err := cmd.Execute() + if clierrors.ExitCodeFrom(err) != clierrors.ExitValidation { + t.Fatalf("exit code = %d (%v), want validation", clierrors.ExitCodeFrom(err), err) + } + if requests != 0 { + t.Errorf("API requests = %d, want 0", requests) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } +} + +func TestReleaseEventsFollowYAMLValidatesBeforeScopeDiscovery(t *testing.T) { + requests := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + cfg := &config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + CurrentStack: rollbackTestStackID, + } + ctx := cmdutil.NewCommandContext(cfg, output.FormatYAML, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + + cmd := newReleaseCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"events", "22222222-2222-2222-2222-222222222222", "--follow"}) + err := cmd.Execute() + if clierrors.ExitCodeFrom(err) != clierrors.ExitValidation { + t.Fatalf("exit code = %d (%v), want validation", clierrors.ExitCodeFrom(err), err) + } + if requests != 0 { + t.Errorf("scope discovery API requests = %d, want 0", requests) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } +} + +func TestReleaseEventsNonFollowYAMLRemainsAllowed(t *testing.T) { + requests := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.URL.Path != "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases/22222222-2222-2222-2222-222222222222/events" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[],"total":0}`)) + })) + defer ts.Close() + + cfg := &config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "proj-1", + CurrentStack: rollbackTestStackID, + } + ctx := cmdutil.NewCommandContext(cfg, output.FormatYAML, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + + cmd := newReleaseCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"events", "22222222-2222-2222-2222-222222222222"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("non-follow YAML events: %v", err) + } + if requests != 1 { + t.Errorf("API requests = %d, want 1", requests) + } + if stdout.Len() == 0 { + t.Fatal("non-follow YAML output is empty") + } +} + +// StreamReleaseEvents closes its channel when the caller's context ends. The +// follow command must turn that otherwise-silent close into the CLI's standard +// cancellation contract instead of reporting success. +func TestReleaseEventsFollowParentCancellationIsUserCancellation(t *testing.T) { + streamStarted := make(chan struct{}) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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/"+rollbackTestStackID+"/releases": + _, _ = w.Write([]byte(`{"items":[{"id":"release-follow"}]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases/release-follow/events/stream": + w.Header().Set("Content-Type", "text/event-stream") + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + close(streamStarted) + <-r.Context().Done() + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cctx, stdout := rollbackCommandContext(ts.URL) + parent, cancel := context.WithCancel(context.Background()) + defer cancel() + cmd := newReleaseCmd() + cmd.SetContext(parent) + cmdutil.SetContext(cmd, cctx) + cmd.SetArgs([]string{"events", "release-follow", "--follow"}) + + errCh := make(chan error, 1) + go func() { errCh <- cmd.Execute() }() + select { + case <-streamStarted: + // Give the client a brief scheduling window to return its event channel + // before cancelling the parent. This targets the close-after-connect + // path rather than the separate initial-request cancellation path. + time.Sleep(20 * time.Millisecond) + cancel() + case <-time.After(time.Second): + t.Fatal("release events stream did not start") + } + + select { + case err := <-errCh: + if err != clierrors.ErrUserCanceled { + t.Fatalf("cancellation error = %v, want ErrUserCanceled", err) + } + case <-time.After(time.Second): + t.Fatal("release events command did not return after cancellation") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want no partial event output", stdout.String()) + } +} + +func rollbackCommandContext(serverURL string) (*cmdutil.CommandContext, *bytes.Buffer) { + cfg := &config.Config{ + ServerURL: serverURL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "proj-1", + CurrentStack: rollbackTestStackID, + } + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + stdout := &bytes.Buffer{} + ctx.Formatter.Writer = stdout + return ctx, stdout +} + +func rollbackObservationServer(t *testing.T, stackResponse, liveResponse string) *httptest.Server { + t.Helper() + var releaseReads int + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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/"+rollbackTestStackID+"/releases": + _, _ = w.Write([]byte(`{"items":[{"id":"release-previous"}]}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","sequence":7,"state":"Pending"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases/release-rollback/events/stream": + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: end\ndata: {}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID: + _, _ = w.Write([]byte(stackResponse)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases/release-rollback": + releaseReads++ + if releaseReads == 1 { + _, _ = w.Write([]byte(`{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","state":"Released"}`)) + return + } + _, _ = w.Write([]byte(liveResponse)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases/release-other": + _, _ = w.Write([]byte(liveResponse)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func rollbackBlockingServer(t *testing.T, streamStarted chan<- struct{}) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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/"+rollbackTestStackID+"/releases": + _, _ = w.Write([]byte(`{"items":[{"id":"release-previous"}]}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","sequence":7,"state":"Pending"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases/release-rollback/events/stream": + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + if streamStarted != nil { + close(streamStarted) + } + <-r.Context().Done() + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func rollbackUnflushedStreamServer(t *testing.T, streamStarted chan<- struct{}) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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/"+rollbackTestStackID+"/releases": + _, _ = w.Write([]byte(`{"items":[{"id":"release-previous"}]}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"release-rollback","stack_id":"` + rollbackTestStackID + `","sequence":7,"state":"Pending"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+rollbackTestStackID+"/releases/release-rollback/events/stream": + // Deliberately do not write or flush headers. This holds + // http.Client.Do inside StreamReleaseEvents until the deadline. + close(streamStarted) + <-r.Context().Done() + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) +} + func TestEventDataJSONStaysValid(t *testing.T) { for _, data := range []string{`{"state":"Released"}`, "plain text \"quoted\"", ""} { line := `{"event":"message","data":` + eventDataJSON(data) + `}` diff --git a/cmd/stackdome/restart.go b/cmd/stackdome/restart.go index a449de8..e4439fd 100644 --- a/cmd/stackdome/restart.go +++ b/cmd/stackdome/restart.go @@ -2,10 +2,9 @@ package main import ( "fmt" - "os" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/cmdutil" ) func newRestartCmd() *cobra.Command { @@ -28,8 +27,11 @@ func newRestartCmd() *cobra.Command { return err } - fmt.Fprintf(os.Stderr, "Restart initiated for resource %q\n", resourceName) - return nil + return printMutationResult(ctx, mutationResult{ + Status: "restart_initiated", + Resource: "stack_resource", + Name: resourceName, + }, fmt.Sprintf("Restart initiated for resource %q", resourceName)) })), } diff --git a/cmd/stackdome/root.go b/cmd/stackdome/root.go index da17156..ebc74dc 100644 --- a/cmd/stackdome/root.go +++ b/cmd/stackdome/root.go @@ -2,16 +2,19 @@ package main import ( "context" + "encoding/json" "fmt" + "io" "log/slog" "os" "os/signal" + "strings" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + "github.com/Stackdome/stackdome-cli/internal/config" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/output" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/cmdutil" - "github.com/stackdome/cli/internal/config" - clierrors "github.com/stackdome/cli/internal/errors" - "github.com/stackdome/cli/internal/output" ) var ( @@ -22,8 +25,8 @@ var ( func newRootCmd() *cobra.Command { rootCmd := &cobra.Command{ - Use: "stackdome", - Short: "CLI for the Stackdome platform", + Use: "stackdome", + Short: "CLI for the Stackdome platform", Long: `Deploy, manage, and monitor your applications on Stackdome. Every command runs non-interactively: pass --yes to skip confirmations, @@ -60,6 +63,7 @@ Exit codes: } ctx := cmdutil.NewCommandContext(cfg, format, level) + ctx.Formatter.Writer = cmd.OutOrStdout() cmdutil.SetContext(cmd, ctx) return nil }, @@ -88,8 +92,12 @@ Exit codes: 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()) // Usage errors exit 4, as the help text above promises. Cobra reports bad @@ -112,25 +120,81 @@ func wrapArgErrors(cmd *cobra.Command) { inner := cmd.Args cmd.Args = func(c *cobra.Command, args []string) error { if err := inner(c, args); err != nil { + // Human-readable modes can pair the validation error with actionable + // help. JSON stderr must remain exactly one error document. + if len(args) == 0 && !c.HasSubCommands() && !commandUsesJSONOutput(c) { + printHelpToStderr(c) + } return clierrors.ValidationError(err.Error()) } return nil } } +func commandUsesJSONOutput(cmd *cobra.Command) bool { + format, err := cmd.Flags().GetString("output") + return err == nil && format == string(output.FormatJSON) +} + +func printHelpToStderr(cmd *cobra.Command) { + stdout := cmd.OutOrStdout() + cmd.SetOut(cmd.ErrOrStderr()) + defer cmd.SetOut(stdout) + _ = cmd.Help() +} + func run() int { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() + return runWithContext(ctx, os.Args[1:], os.Stdout, os.Stderr) +} + +func runWithWriters(args []string, stdout, stderr io.Writer) int { + return runWithContext(context.Background(), args, stdout, stderr) +} +func runWithContext(ctx context.Context, args []string, stdout, stderr io.Writer) int { + jsonErrors := requestsJSONOutput(args) rootCmd := newRootCmd() + rootCmd.SetArgs(args) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) if err := rootCmd.ExecuteContext(ctx); err != nil { msg := clierrors.UserMessage(err) - fmt.Fprintf(os.Stderr, "Error: %s\n", msg) - return clierrors.ExitCodeFrom(err) + exitCode := clierrors.ExitCodeFrom(err) + if jsonErrors { + _ = json.NewEncoder(stderr).Encode(struct { + Error string `json:"error"` + ExitCode int `json:"exit_code"` + }{Error: msg, ExitCode: exitCode}) + } else { + fmt.Fprintf(stderr, "Error: %s\n", msg) + } + return exitCode } return 0 } +func requestsJSONOutput(args []string) bool { + jsonOutput := false + for i := 0; i < len(args); i++ { + switch { + case args[i] == "-o" || args[i] == "--output": + if i+1 < len(args) { + jsonOutput = args[i+1] == string(output.FormatJSON) + i++ + } + case strings.HasPrefix(args[i], "-o="): + jsonOutput = strings.TrimPrefix(args[i], "-o=") == string(output.FormatJSON) + case strings.HasPrefix(args[i], "--output="): + jsonOutput = strings.TrimPrefix(args[i], "--output=") == string(output.FormatJSON) + case len(args[i]) > len("-o") && strings.HasPrefix(args[i], "-o"): + jsonOutput = strings.TrimPrefix(args[i], "-o") == string(output.FormatJSON) + } + } + return jsonOutput +} + func parseLogLevel(s string) slog.Level { switch s { case "debug": diff --git a/cmd/stackdome/root_test.go b/cmd/stackdome/root_test.go new file mode 100644 index 0000000..25f9500 --- /dev/null +++ b/cmd/stackdome/root_test.go @@ -0,0 +1,246 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Stackdome/stackdome-cli/internal/config" +) + +// Removing either command from the root makes documented agent recovery and +// 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}) + if err != nil { + t.Fatalf("find %s: %v", name, err) + } + if command == root || command.Name() != name { + t.Errorf("root command %q is not registered", name) + } + } +} + +// If root error handling regresses to prose in JSON mode, automation can no +// longer decode failures even though stdout correctly contains no result. +func TestRunWithWritersEmitsJSONErrorOnlyOnStderr(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := runWithWriters([]string{"--output", "json", "--not-a-flag"}, &stdout, &stderr) + + if code != 4 { + t.Fatalf("exit code = %d, want 4", code) + } + if got := stdout.String(); got != "" { + t.Fatalf("stdout = %q, want empty", got) + } + var got struct { + Error string `json:"error"` + ExitCode int `json:"exit_code"` + } + if err := json.Unmarshal(stderr.Bytes(), &got); err != nil { + t.Fatalf("stderr is not a JSON error document: %v\nstderr: %s", err, stderr.String()) + } + if got.Error == "" { + t.Error("error document has an empty error") + } + if got.ExitCode != 4 { + t.Errorf("error document exit_code = %d, want 4", got.ExitCode) + } +} + +// Cobra stops parsing at an unknown flag, so JSON error selection must inspect +// the original argv rather than depend on whether the output flag was reached. +func TestRunWithWritersFindsJSONOutputAfterInvalidFlag(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "short separate", args: []string{"--not-a-flag", "-o", "json"}}, + {name: "long separate", args: []string{"--not-a-flag", "--output", "json"}}, + {name: "short equals", args: []string{"--not-a-flag", "-o=json"}}, + {name: "long equals", args: []string{"--not-a-flag", "--output=json"}}, + {name: "short attached", args: []string{"-ojson", "--not-a-flag"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runWithWriters(tt.args, &stdout, &stderr) + + if code == 0 { + t.Fatal("exit code = 0, want failure") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + var got struct { + Error string `json:"error"` + ExitCode int `json:"exit_code"` + } + if err := json.Unmarshal(stderr.Bytes(), &got); err != nil { + t.Fatalf("stderr is not JSON: %v\nstderr: %s", err, stderr.String()) + } + if got.Error == "" || got.ExitCode != code { + t.Errorf("error document = %#v, want non-empty error and exit_code %d", got, code) + } + }) + } +} + +func TestRunWithWritersShowsLeafHelpWhenRequiredArgsAreMissing(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "table", args: []string{"secret", "create"}}, + {name: "yaml", args: []string{"secret", "create", "--output", "yaml"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := runWithWriters(tt.args, &stdout, &stderr) + + if code != 4 { + t.Fatalf("exit code = %d, want 4", code) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + const usage = "Usage:\n stackdome secret create [flags]" + if !strings.Contains(stderr.String(), usage) { + t.Errorf("stderr does not contain command help usage %q:\n%s", usage, stderr.String()) + } + if !strings.Contains(stderr.String(), "Error: accepts 1 arg(s), received 0") { + t.Errorf("stderr does not contain validation error:\n%s", stderr.String()) + } + }) + } +} + +func TestRunWithWritersKeepsMissingArgsErrorMachineReadableInJSONMode(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := runWithWriters([]string{"secret", "create", "--output", "json"}, &stdout, &stderr) + + if code != 4 { + t.Fatalf("exit code = %d, want 4", code) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + var got struct { + Error string `json:"error"` + ExitCode int `json:"exit_code"` + } + if err := json.Unmarshal(stderr.Bytes(), &got); err != nil { + t.Fatalf("stderr is not one JSON error document: %v\nstderr: %s", err, stderr.String()) + } + if got.Error != "accepts 1 arg(s), received 0" || got.ExitCode != 4 { + t.Errorf("error document = %#v, want missing-argument validation error", got) + } +} + +func TestRunWithWritersLeavesZeroArgumentCommandsUnchanged(t *testing.T) { + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + var stdout, stderr bytes.Buffer + + code := runWithWriters([]string{"version"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String()) + } + if !strings.HasPrefix(stdout.String(), "stackdome ") { + t.Errorf("stdout = %q, want version output", stdout.String()) + } + if stderr.Len() != 0 { + t.Errorf("stderr = %q, want empty", stderr.String()) + } +} + +// Cancellation is observable at the process boundary: a JSON follow command +// must retain its one-error-document stderr contract and exit 130 even when +// the release event channel closes quietly as the parent context is cancelled. +func TestRunWithContextReleaseEventsFollowCancellationUsesJSONErrorContract(t *testing.T) { + const stackID = "11111111-1111-1111-1111-111111111111" + streamStarted := make(chan struct{}) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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/"+stackID+"/releases": + _, _ = w.Write([]byte(`{"items":[{"id":"release-follow"}]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+stackID+"/releases/release-follow/events/stream": + w.Header().Set("Content-Type", "text/event-stream") + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + close(streamStarted) + <-r.Context().Done() + 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", + CurrentStack: stackID, + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + parent, cancel := context.WithCancel(context.Background()) + defer cancel() + 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) + }() + + select { + case <-streamStarted: + time.Sleep(20 * time.Millisecond) + cancel() + case <-time.After(time.Second): + t.Fatal("release events stream did not start") + } + + select { + case code := <-codeCh: + if code != 130 { + t.Fatalf("exit code = %d, want 130", code) + } + case <-time.After(time.Second): + t.Fatal("root command did not return after cancellation") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + var got struct { + Error string `json:"error"` + ExitCode int `json:"exit_code"` + } + if err := json.Unmarshal(stderr.Bytes(), &got); err != nil { + t.Fatalf("stderr is not a JSON error document: %v\nstderr: %s", err, stderr.String()) + } + if got.Error != "Aborted." || got.ExitCode != 130 { + t.Errorf("error document = %#v, want user cancellation", got) + } +} diff --git a/cmd/stackdome/secret.go b/cmd/stackdome/secret.go index ba96992..fe32ff3 100644 --- a/cmd/stackdome/secret.go +++ b/cmd/stackdome/secret.go @@ -6,12 +6,12 @@ import ( "strings" "time" + "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/joho/godotenv" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/cmdutil" - clierrors "github.com/stackdome/cli/internal/errors" - "github.com/stackdome/cli/internal/output" ) func newSecretCmd() *cobra.Command { @@ -236,8 +236,12 @@ func newSecretDeleteCmd() *cobra.Command { return err } - fmt.Fprintf(os.Stderr, "Secret %q deleted.\n", args[0]) - return nil + return printMutationResult(ctx, mutationResult{ + Status: "deleted", + Resource: "secret", + Name: args[0], + ID: secret.GetId(), + }, fmt.Sprintf("Secret %q deleted.", args[0])) })), } diff --git a/cmd/stackdome/signup.go b/cmd/stackdome/signup.go index ff1ca43..514f408 100644 --- a/cmd/stackdome/signup.go +++ b/cmd/stackdome/signup.go @@ -1,13 +1,10 @@ package main import ( - "fmt" - "os" - + "github.com/Stackdome/stackdome-cli/internal/client" + "github.com/Stackdome/stackdome-cli/internal/config" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/client" - "github.com/stackdome/cli/internal/config" - clierrors "github.com/stackdome/cli/internal/errors" ) func newSignupCmd() *cobra.Command { @@ -64,8 +61,7 @@ func newSignupCmd() *cobra.Command { return err } - fmt.Fprintf(os.Stderr, "Account created. Logged in as %s\n", cfg.Username) - return nil + return printAuthenticationResult(cmd, cfg, true, "session") }, } diff --git a/cmd/stackdome/stack.go b/cmd/stackdome/stack.go index 24a13d8..ff00b51 100644 --- a/cmd/stackdome/stack.go +++ b/cmd/stackdome/stack.go @@ -4,10 +4,10 @@ import ( "fmt" "os" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/output" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/cmdutil" - clierrors "github.com/stackdome/cli/internal/errors" - "github.com/stackdome/cli/internal/output" ) func newStackCmd() *cobra.Command { @@ -17,11 +17,49 @@ func newStackCmd() *cobra.Command { } cmd.AddCommand(newStackListCmd()) + cmd.AddCommand(newStackUseCmd()) cmd.AddCommand(newStackInfoCmd()) cmd.AddCommand(newStackDeleteCmd()) return cmd } +func newStackUseCmd() *cobra.Command { + return &cobra.Command{ + Use: "use ", + Aliases: []string{"select"}, + 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") + }), + } +} + +func selectStackContext(ctx *cmdutil.CommandContext, cmd *cobra.Command, ref, commandName string) error { + if err := ctx.Config.RequireAuth(); err != nil { + return err + } + if ctx.Config.StackContextFromEnv() { + return clierrors.ValidationError(commandName + " cannot persist a selection while STACKDOME_URL, STACKDOME_TOKEN, STACKDOME_ORG, or STACKDOME_PROJECT controls the active context; unset the override or pass --stack to the command instead") + } + if err := cmdutil.ResolveScope(ctx, cmd); err != nil { + return err + } + id, err := resolveStackRef(ctx, cmd, ref) + if err != nil { + return err + } + if err := ctx.Config.SetCurrentStack(id); err != nil { + return err + } + return printMutationResult(ctx, mutationResult{ + Status: "selected", + Resource: "stack", + Name: ref, + ID: id, + }, fmt.Sprintf("Current stack set to %s (%s)", ref, id)) +} + func newStackListCmd() *cobra.Command { return &cobra.Command{ Use: "list", @@ -66,16 +104,17 @@ func newStackListCmd() *cobra.Command { func newStackInfoCmd() *cobra.Command { return &cobra.Command{ - Use: "info ", - Short: "Show detailed stack info", + Use: "info ", + Short: "Show detailed stack info 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 !ctx.Formatter.IsTable() { @@ -121,8 +160,12 @@ func newStackDeleteCmd() *cobra.Command { _ = ctx.Config.SetCurrentStack("") } - fmt.Fprintf(os.Stderr, "Stack %q deletion initiated.\n", stack.Name) - return nil + return printMutationResult(ctx, mutationResult{ + Status: "deletion_initiated", + Resource: "stack", + Name: stack.Name, + ID: stack.GetId(), + }, fmt.Sprintf("Stack %q deletion initiated.", stack.Name)) })), } diff --git a/cmd/stackdome/stack_test.go b/cmd/stackdome/stack_test.go new file mode 100644 index 0000000..8117f51 --- /dev/null +++ b/cmd/stackdome/stack_test.go @@ -0,0 +1,336 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + "github.com/Stackdome/stackdome-cli/internal/config" + "github.com/Stackdome/stackdome-cli/internal/output" +) + +func TestStackUseSelectsExistingStackAndPersistsFullID(t *testing.T) { + const stackID = "f8ac5eee-e489-44be-955e-7b90f3cd2a07" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodGet || r.URL.Path != "/api/v1/organizations/org-1/projects/default/stacks" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"items":[{"id":"` + stackID + `","name":"n8n","spec":{}}],"total":1}`)) + })) + 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: "default", + } + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + + cmd := newStackCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"use", "n8n"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("stack use: %v", err) + } + if cfg.CurrentStack != stackID { + t.Fatalf("current stack = %q, want full ID %q", cfg.CurrentStack, stackID) + } + persisted, err := config.LoadFrom(configPath) + if err != nil { + t.Fatalf("load persisted config: %v", err) + } + if persisted.CurrentStack != stackID { + t.Fatalf("persisted current stack = %q, want %q", persisted.CurrentStack, stackID) + } + + var got mutationResult + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("stdout is not a JSON selection result: %v\n%s", err, stdout.String()) + } + if got.Status != "selected" || got.Resource != "stack" || got.Name != "n8n" || got.ID != stackID { + t.Errorf("selection result = %#v", got) + } +} + +func TestStackUseResolvesMissingPersistedScope(t *testing.T) { + const stackID = "f8ac5eee-e489-44be-955e-7b90f3cd2a07" + var paths []string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/users/current": + _, _ = w.Write([]byte(`{"id":"user-1","email":"agent@example.com","organisation_id":"org-1"}`)) + case "/api/v1/users/current/projects": + _, _ = w.Write([]byte(`{"items":[{"name":"default","default_project":true}],"total":1}`)) + case "/api/v1/organizations/org-1/projects/default/stacks": + _, _ = w.Write([]byte(`{"items":[{"id":"` + stackID + `","name":"n8n","spec":{}}],"total":1}`)) + 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") + body := `{"server_url":"` + ts.URL + `","access_token":"file-token"}` + if err := os.WriteFile(configPath, []byte(body), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("STACKDOME_CONFIG", configPath) + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + + cmd := newStackCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"use", "n8n"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("stack use: %v; paths: %v", err, paths) + } + if cfg.OrganizationID != "org-1" || cfg.ProjectName != "default" || cfg.CurrentStack != stackID { + t.Fatalf("resolved context = org %q, project %q, stack %q", cfg.OrganizationID, cfg.ProjectName, cfg.CurrentStack) + } + wantPaths := []string{ + "/api/v1/users/current", + "/api/v1/users/current/projects", + "/api/v1/organizations/org-1/projects/default/stacks", + } + if strings.Join(paths, "\n") != strings.Join(wantPaths, "\n") { + t.Fatalf("paths = %v, want %v", paths, wantPaths) + } +} + +func TestStackSelectAliasIsDiscoverable(t *testing.T) { + cmd := newStackCmd() + found, _, err := cmd.Find([]string{"select"}) + if err != nil { + t.Fatalf("find stack select alias: %v", err) + } + if found.Name() != "use" { + t.Fatalf("stack select resolves to %q, want use", found.Name()) + } +} + +func TestStackUseWithEnvironmentTokenRejectsBeforeScopeDiscovery(t *testing.T) { + requests := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "missing.json")) + t.Setenv("STACKDOME_URL", ts.URL) + t.Setenv("STACKDOME_TOKEN", "sdm_ephemeral") + + var stdout, stderr bytes.Buffer + code := runWithWriters([]string{"stack", "use", "n8n", "-o", "json"}, &stdout, &stderr) + if code != 4 { + t.Fatalf("exit code = %d, want validation exit 4; stderr: %s", code, stderr.String()) + } + if requests != 0 { + t.Fatalf("requests = %d, want zero before rejecting ephemeral selection", requests) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty on failure", stdout.String()) + } + if !strings.Contains(stderr.String(), "--stack") { + t.Fatalf("stderr = %q, want --stack remediation", stderr.String()) + } +} + +func TestStackUseRejectsEnvironmentSelectionOverridesWithoutChangingFileContext(t *testing.T) { + tests := []struct { + name string + key string + value func(string) string + }{ + {name: "server", key: "STACKDOME_URL", value: func(serverURL string) string { return serverURL }}, + {name: "organization", key: "STACKDOME_ORG", value: func(string) string { return "env-org" }}, + {name: "project", key: "STACKDOME_PROJECT", value: func(string) string { return "env-project" }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + requests := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[{"id":"env-stack","name":"n8n","spec":{}}],"total":1}`)) + })) + defer ts.Close() + + configPath := filepath.Join(t.TempDir(), "config.json") + body := `{"server_url":"` + ts.URL + `","access_token":"file-token","organization_id":"file-org","project_name":"default","current_stack":"file-stack"}` + if err := os.WriteFile(configPath, []byte(body), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("STACKDOME_CONFIG", configPath) + t.Setenv(tt.key, tt.value(ts.URL)) + + var stdout, stderr bytes.Buffer + code := runWithWriters([]string{"stack", "use", "n8n", "-o", "json"}, &stdout, &stderr) + if code != 4 { + t.Fatalf("exit code = %d, want validation exit 4; stderr: %s", code, stderr.String()) + } + if requests != 0 { + t.Fatalf("requests = %d, want zero before rejecting environment override", requests) + } + if !strings.Contains(stderr.String(), "--stack") { + t.Fatalf("stderr = %q, want --stack remediation", stderr.String()) + } + + persisted, err := config.LoadFrom(configPath) + if err != nil { + t.Fatal(err) + } + if persisted.CurrentStack != "file-stack" || persisted.OrganizationID != "file-org" || persisted.ProjectName != "default" { + t.Fatalf("file context changed: %#v", persisted) + } + }) + } +} + +func TestStackInfoResolvesIDReferencesBeforeFetchingDetails(t *testing.T) { + const stackID = "f8ac5eee-e489-44be-955e-7b90f3cd2a07" + tests := []struct { + name string + ref string + }{ + {name: "name", ref: "n8n"}, + {name: "full ID", ref: stackID}, + {name: "unique ID prefix", ref: "f8ac5eee"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var paths []string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/organizations/org-1/projects/default/stacks": + _, _ = w.Write([]byte(`{"items":[{"id":"` + stackID + `","name":"n8n","spec":{}}],"total":1}`)) + case "/api/v1/organizations/org-1/projects/default/stacks/" + stackID: + _, _ = w.Write([]byte(`{"id":"` + stackID + `","name":"n8n","spec":{}}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfg := &config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "default", + } + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + + cmd := newStackInfoCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{tt.ref}) + if err := cmd.Execute(); err != nil { + t.Fatalf("stack info %q: %v; paths: %v", tt.ref, err, paths) + } + + 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 stack JSON: %v\nstdout: %s", err, stdout.String()) + } + if got.ID != stackID || got.Name != "n8n" { + t.Errorf("stack info result = %#v, want n8n %s", got, stackID) + } + wantPaths := []string{ + "/api/v1/organizations/org-1/projects/default/stacks", + "/api/v1/organizations/org-1/projects/default/stacks/" + stackID, + } + if strings.Join(paths, "\n") != strings.Join(wantPaths, "\n") { + t.Fatalf("paths = %v, want %v", paths, wantPaths) + } + }) + } +} + +func TestStackDeleteJSONPrintsStructuredResult(t *testing.T) { + var deleted bool + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + 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.MethodDelete && r.URL.Path == "/api/v1/organizations/org-1/projects/default/stacks/stack-1": + deleted = true + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfg := &config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "default", + } + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + + cmd := newStackDeleteCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"app", "--yes"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("stack delete: %v", err) + } + if !deleted { + t.Fatal("stack delete did not call the API") + } + + var got struct { + Status string `json:"status"` + Resource string `json:"resource"` + Name string `json:"name"` + ID string `json:"id"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("stdout is not a JSON result: %v\nstdout: %s", err, stdout.String()) + } + if got.Status != "deletion_initiated" || got.Resource != "stack" || got.Name != "app" || got.ID != "stack-1" { + t.Errorf("result = %#v, want deletion_initiated stack app stack-1", got) + } +} diff --git a/cmd/stackdome/stackfile.go b/cmd/stackdome/stackfile.go new file mode 100644 index 0000000..5a7b534 --- /dev/null +++ b/cmd/stackdome/stackfile.go @@ -0,0 +1,291 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/Stackdome/stackdome-cli/internal/client" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/stackfile" + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" + "github.com/Stackdome/stackdome/pkg/models" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +func newStackfileCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "stackfile", + Short: "Inspect and export canonical Stackfiles", + } + cmd.AddCommand(newStackfileSchemaCmd()) + cmd.AddCommand(newStackfileExportCmd()) + return cmd +} + +func newStackfileSchemaCmd() *cobra.Command { + var ( + format string + outputFile string + ) + cmd := &cobra.Command{ + Use: "schema", + 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") + } + if outputFile != "" { + if err := os.WriteFile(outputFile, stackfile.SchemaJSON, 0o644); err != nil { + return clierrors.Wrapf(err, "Failed to write Stackfile schema: %s", outputFile) + } + return nil + } + _, err := cmd.OutOrStdout().Write(stackfile.SchemaJSON) + if err != nil { + return clierrors.Wrap(err, "Failed to write Stackfile schema") + } + _, err = fmt.Fprintln(cmd.OutOrStdout()) + return err + }, + } + cmd.Flags().StringVarP(&format, "output", "o", "json", "Output format (json)") + cmd.Flags().StringVar(&outputFile, "output-file", "", "Write the exact embedded JSON Schema to this file (requires -o json)") + return cmd +} + +func newStackfileExportCmd() *cobra.Command { + var ( + format string + outputFile string + ) + + cmd := &cobra.Command{ + Use: "export ", + Short: "Export a stack as canonical Stackfile content", + 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") + } + + stackID, err := resolveStackRef(ctx, cmd, args[0]) + if err != nil { + return err + } + stack, err := ctx.Client.GetStack(cmd.Context(), stackID) + if err != nil { + return err + } + if err := validateExportSettings(stack); err != nil { + return clierrors.Wrapf(err, "Cannot export stack as Stackfile: %s", err) + } + if err := restoreExportConnectionNames(cmd.Context(), ctx.Client, stack); err != nil { + return clierrors.Wrap(err, "Cannot export stack as Stackfile").WithDetail(err.Error()) + } + normalizeStackfileExportInput(stack) + sf, err := stackfile.FromStack(stack) + if err != nil { + return stackfileConversionError(err) + } + + content, err := marshalStackfile(sf, format) + if err != nil { + return clierrors.Wrap(err, "Failed to encode Stackfile") + } + if outputFile != "" { + if err := os.WriteFile(outputFile, content, 0o644); err != nil { + return clierrors.Wrapf(err, "Failed to write Stackfile: %s", outputFile) + } + return nil + } + _, err = cmd.OutOrStdout().Write(content) + return err + })), + } + cmd.Flags().StringVarP(&format, "output", "o", "yaml", "Output format (yaml, json)") + cmd.Flags().StringVar(&outputFile, "output-file", "", "Write Stackfile content to this file") + return cmd +} + +func stackfileConversionError(err error) error { + // The canonical exporter uses this prefix only for structural features the + // Stackfile schema cannot express. Other conversion/validation errors may + // contain environment values and must remain private. + if strings.HasPrefix(err.Error(), "not expressible in a stackfile: ") { + return clierrors.Wrapf(err, "Cannot export stack as Stackfile: %s", err) + } + return clierrors.Wrap(err, "Cannot export stack as Stackfile") +} + +// normalizeStackfileExportInput removes API facts that are not independent +// desired state. RestartRequestTime is an imperative action, while volume-mount +// connections duplicate StackResource.VolumeMounts (the source used for actual +// deployment and for Stackfile output). Replaying the timestamp or rejecting a +// stale canvas edge would both make a valid UI-created stack non-editable. +func normalizeStackfileExportInput(stack *openapi.Stack) { + for i := range stack.Spec.StackResources { + stack.Spec.StackResources[i].LifecycleConfig = nil + } + + connections := make([]openapi.StackConnection, 0, len(stack.Spec.Connections)) + for _, connection := range stack.Spec.Connections { + if !normalizeVolumeMountConnection(stack, connection) { + connections = append(connections, connection) + } + } + stack.Spec.Connections = connections +} + +func normalizeVolumeMountConnection(stack *openapi.Stack, connection openapi.StackConnection) bool { + if connection.Kind != string(models.ConnectionKindVolumeMount) || + connection.From.Type != string(models.TopologyNodeTypeVolume) || + connection.To.Type != string(models.TopologyNodeTypeStackResource) || + connection.From.Name == nil || connection.To.Name == nil { + return false + } + + mountPath := "" + if connection.Config != nil { + config := connection.Config.VolumeMountConfig + if config == nil || config.SubPath != nil || config.ReadOnly != nil { + return false + } + mountPath = config.MountPath + } + + volumeExists := false + for _, volume := range stack.Spec.Volumes { + if volume.Name == *connection.From.Name { + volumeExists = true + break + } + } + if !volumeExists { + return false + } + + for i := range stack.Spec.StackResources { + resource := &stack.Spec.StackResources[i] + if resource.Name != *connection.To.Name { + continue + } + for _, mount := range resource.VolumeMounts { + if mount.SourceVolumeName == *connection.From.Name { + return true + } + } + if mountPath == "" { + return false + } + resource.VolumeMounts = append(resource.VolumeMounts, openapi.VolumeMount{ + SourceVolumeName: *connection.From.Name, + TargetPath: mountPath, + }) + return true + } + return false +} + +func validateExportSettings(stack *openapi.Stack) error { + releaseRetention := int32(models.DefaultReleaseRetentionLimit) + minSuccessful := int32(models.DefaultMinSuccessfulReleases) + if stack.Settings != nil { + if configured := stack.Settings.ReleaseRetentionLimit; configured != nil && *configured > 0 { + releaseRetention = *configured + } + if configured := stack.Settings.MinSuccessfulReleases; configured != nil && *configured > 0 { + minSuccessful = *configured + } + } + + if releaseRetention == int32(models.DefaultReleaseRetentionLimit) && minSuccessful == int32(models.DefaultMinSuccessfulReleases) { + return nil + } + return fmt.Errorf( + "stack settings cannot be represented: release_retention_limit=%d (default %d), min_successful_releases=%d (default %d)", + releaseRetention, + models.DefaultReleaseRetentionLimit, + minSuccessful, + models.DefaultMinSuccessfulReleases, + ) +} + +func restoreExportConnectionNames(ctx context.Context, c *client.Client, stack *openapi.Stack) error { + var ( + secretNames map[string]string + postgresNames map[string]string + ) + + for i := range stack.Spec.Connections { + from := &stack.Spec.Connections[i].From + if from.Name != nil && *from.Name != "" { + continue + } + + switch from.Type { + case string(models.TopologyNodeTypeSecret): + if from.Id == nil || *from.Id == "" { + return fmt.Errorf("secret connection ref has neither name nor ID") + } + if secretNames == nil { + secrets, err := c.ListSecrets(ctx) + if err != nil { + return err + } + secretNames = make(map[string]string, len(secrets)) + for _, secret := range secrets { + if secret.Id != nil && *secret.Id != "" { + secretNames[*secret.Id] = secret.Name + } + } + } + name, ok := secretNames[*from.Id] + if !ok || name == "" { + return fmt.Errorf("secret ID %q could not be resolved to a name", *from.Id) + } + from.Name = &name + + case string(models.TopologyNodeTypePostgresAddon): + if from.Id == nil || *from.Id == "" { + return fmt.Errorf("postgres addon connection ref has neither name nor ID") + } + if postgresNames == nil { + addons, err := c.ListPostgresAddons(ctx) + if err != nil { + return err + } + postgresNames = make(map[string]string, len(addons)) + for _, addon := range addons { + if addon.Id != nil && *addon.Id != "" { + postgresNames[*addon.Id] = addon.Name + } + } + } + name, ok := postgresNames[*from.Id] + if !ok || name == "" { + return fmt.Errorf("postgres addon ID %q could not be resolved to a name", *from.Id) + } + from.Name = &name + } + } + + return nil +} + +func marshalStackfile(sf *stackfile.Stackfile, format string) ([]byte, error) { + content, err := yaml.Marshal(sf) + if err != nil || format != "json" { + return content, err + } + + var document any + if err := yaml.Unmarshal(content, &document); err != nil { + return nil, err + } + return json.MarshalIndent(document, "", " ") +} diff --git a/cmd/stackdome/stackfile_test.go b/cmd/stackdome/stackfile_test.go new file mode 100644 index 0000000..faa45ee --- /dev/null +++ b/cmd/stackdome/stackfile_test.go @@ -0,0 +1,660 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + "github.com/Stackdome/stackdome-cli/internal/config" + "github.com/Stackdome/stackdome-cli/internal/output" + internalstackfile "github.com/Stackdome/stackdome-cli/internal/stackfile" + hub "github.com/Stackdome/stackdome/pkg/stackfile" +) + +func TestStackfileSchemaJSONIsDraft7Schema(t *testing.T) { + cmd := newStackfileCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"schema", "-o", "json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("schema: %v", err) + } + + var schema map[string]any + if err := json.Unmarshal(stdout.Bytes(), &schema); err != nil { + t.Fatalf("schema output is not JSON: %v\n%s", err, stdout.String()) + } + if got := schema["$schema"]; got != "http://json-schema.org/draft-07/schema#" { + t.Fatalf("$schema = %v, want draft-07", got) + } +} + +func TestStackfileSchemaAcceptsOutputFlagWhenRegisteredUnderRoot(t *testing.T) { + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + root := newRootCmd() + root.AddCommand(newStackfileCmd()) + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"stackfile", "schema", "-o", "json"}) + + if err := root.Execute(); err != nil { + t.Fatalf("root stackfile schema: %v", err) + } + var schema map[string]any + if err := json.Unmarshal(stdout.Bytes(), &schema); err != nil { + t.Fatalf("schema output is not JSON: %v\n%s", err, stdout.String()) + } +} + +func TestStackfileSchemaOutputFileWritesExactEmbeddedBytes(t *testing.T) { + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + outputPath := filepath.Join(t.TempDir(), "stackfile.schema.json") + root := newRootCmd() + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"stackfile", "schema", "--output-file", outputPath}) + + if err := root.Execute(); err != nil { + t.Fatalf("root stackfile schema --output-file: %v", err) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty for file output", stdout.String()) + } + content, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read schema output: %v", err) + } + if !bytes.Equal(content, internalstackfile.SchemaJSON) { + t.Fatalf("schema file differs from embedded bytes: got %d bytes, want %d", len(content), len(internalstackfile.SchemaJSON)) + } +} + +func TestStackfileExportWritesCanonicalContent(t *testing.T) { + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1": + _, _ = w.Write([]byte(`{"id":"stack-1","name":"app","settings":{"release_retention_limit":10,"min_successful_releases":5},"spec":{"stack_resources":[{"name":"web","source":{"image":{"ref":"nginx:alpine"}},"ports":[{"name":"http","number":80,"exposed_to_public":true}]}]}}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfg := &config.Config{ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", ProjectName: "proj-1"} + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + cmd := newStackfileCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"export", "app", "-o", "json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("export: %v", err) + } + + var exported map[string]any + if err := json.Unmarshal(stdout.Bytes(), &exported); err != nil { + t.Fatalf("export output is not JSON: %v\n%s", err, stdout.String()) + } + if exported["name"] != "app" { + t.Fatalf("exported name = %v, want app", exported["name"]) + } + + outputPath := filepath.Join(t.TempDir(), "stackfile.yaml") + cmd = newStackfileCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"export", "app", "--output-file", outputPath}) + if err := cmd.Execute(); err != nil { + t.Fatalf("export to file: %v", err) + } + content, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read exported file: %v", err) + } + if _, err := hub.Load(content); err != nil { + t.Fatalf("exported file is not canonical stackfile content: %v\n%s", err, content) + } +} + +func TestStackfileExportRejectsUnsupportedConstructs(t *testing.T) { + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1": + _, _ = w.Write([]byte(`{"id":"stack-1","name":"app","spec":{"stack_resources":[{"name":"web","init_spec":{"containers":[]},"source":{"image":{"ref":"nginx:alpine"}}}]}}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfg := &config.Config{ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", ProjectName: "proj-1"} + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + cmd := newStackfileCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"export", "app"}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "init_spec") { + t.Fatalf("export error = %v, want unsupported init_spec error", err) + } +} + +// A restart request is an operational timestamp, not desired stack state. If +// export treats it as declarative configuration, merely restarting a UI-created +// resource permanently prevents that stack from being edited as a Stackfile. +func TestStackfileExportIgnoresOperationalRestartRequest(t *testing.T) { + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1": + _, _ = w.Write([]byte(`{"id":"stack-1","name":"app","spec":{"stack_resources":[{"name":"web","source":{"image":{"ref":"nginx:alpine"}},"lifecycle_config":{"restart_request_time":"2026-08-08T10:00:00Z"}}]}}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfg := &config.Config{ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", ProjectName: "proj-1"} + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + cmd := newStackfileCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"export", "app", "-o", "json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("export after restart: %v", err) + } + var exported map[string]any + if err := json.Unmarshal(stdout.Bytes(), &exported); err != nil { + t.Fatalf("export output is not JSON: %v\n%s", err, stdout.String()) + } + if exported["name"] != "app" { + t.Fatalf("exported name = %v, want app", exported["name"]) + } +} + +// Volume mounts live canonically on StackResource.VolumeMounts. The topology +// connection is a duplicate UI-canvas edge; stale edges must not prevent the +// resource's actual deployment mount from round-tripping through Stackfile. +func TestStackfileExportIgnoresStaleVolumeMountConnection(t *testing.T) { + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1": + _, _ = w.Write([]byte(`{ + "id":"stack-1", + "name":"app", + "spec":{ + "volumes":[{"name":"data","spec":{"size":"1Gi","access_mode":"ReadWriteOnce"}}], + "stack_resources":[{ + "name":"web", + "source":{"image":{"ref":"nginx:alpine"}}, + "volume_mounts":[{"source_volume_name":"data","target_path":"/data"}] + }], + "connections":[{ + "kind":"volume_mount", + "from":{"type":"volume","name":"data"}, + "to":{"type":"stack_resource","name":"web"}, + "config":{"mount_path":"/stale-path"} + }] + } +}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfg := &config.Config{ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", ProjectName: "proj-1"} + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + cmd := newStackfileCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"export", "app", "-o", "json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("export with stale volume edge: %v", err) + } + var exported struct { + Resources map[string]struct { + Volumes []struct { + Name string `json:"name"` + Path string `json:"path"` + } `json:"volumes"` + } `json:"resources"` + } + if err := json.Unmarshal(stdout.Bytes(), &exported); err != nil { + t.Fatalf("export output is not JSON: %v\n%s", err, stdout.String()) + } + mounts := exported.Resources["web"].Volumes + if len(mounts) != 1 || mounts[0].Name != "data" || mounts[0].Path != "/data" { + t.Fatalf("exported mounts = %#v, want data mounted at /data", mounts) + } +} + +// Older UI-created stacks can store an expressible mount only as a topology +// edge. Materialize it on the resource before removing the duplicate edge so +// the mount survives the Stackfile round trip. +func TestStackfileExportMaterializesEdgeOnlyVolumeMountConnection(t *testing.T) { + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1": + _, _ = w.Write([]byte(`{ + "id":"stack-1", + "name":"app", + "spec":{ + "volumes":[{"name":"data","spec":{"size":"1Gi","access_mode":"ReadWriteOnce"}}], + "stack_resources":[{"name":"web","source":{"image":{"ref":"nginx:alpine"}}}], + "connections":[{ + "kind":"volume_mount", + "from":{"type":"volume","name":"data"}, + "to":{"type":"stack_resource","name":"web"}, + "config":{"mount_path":"/data"} + }] + } +}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfg := &config.Config{ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", ProjectName: "proj-1"} + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + cmd := newStackfileCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"export", "app", "-o", "json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("export edge-only volume mount: %v", err) + } + var exported struct { + Resources map[string]struct { + Volumes []struct { + Name string `json:"name"` + Path string `json:"path"` + } `json:"volumes"` + } `json:"resources"` + } + if err := json.Unmarshal(stdout.Bytes(), &exported); err != nil { + t.Fatalf("export output is not JSON: %v\n%s", err, stdout.String()) + } + mounts := exported.Resources["web"].Volumes + if len(mounts) != 1 || mounts[0].Name != "data" || mounts[0].Path != "/data" { + t.Fatalf("exported mounts = %#v, want edge materialized as data at /data", mounts) + } +} + +// Edge options Stackfile cannot represent must remain visible to the Hub +// exporter so it fails instead of silently discarding them. +func TestStackfileExportPreservesNonRedundantVolumeMountConnections(t *testing.T) { + tests := []struct { + name string + stackJSON string + }{ + { + name: "edge with sub path", + stackJSON: `{ + "id":"stack-1", + "name":"app", + "spec":{ + "volumes":[{"name":"data","spec":{"size":"1Gi","access_mode":"ReadWriteOnce"}}], + "stack_resources":[{ + "name":"web", + "source":{"image":{"ref":"nginx:alpine"}}, + "volume_mounts":[{"source_volume_name":"data","target_path":"/data"}] + }], + "connections":[{ + "kind":"volume_mount", + "from":{"type":"volume","name":"data"}, + "to":{"type":"stack_resource","name":"web"}, + "config":{"mount_path":"/data","sub_path":"nested"} + }] + } +}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1": + _, _ = w.Write([]byte(tt.stackJSON)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfg := &config.Config{ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", ProjectName: "proj-1"} + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + cmd := newStackfileCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"export", "app", "-o", "json"}) + + if err := cmd.Execute(); err == nil { + t.Fatal("export succeeded, want failure rather than silently dropping the volume edge") + } + }) + } +} + +// The process boundary must retain the safe conversion reason. Otherwise a +// human or agent sees only "Cannot export" and has no path to remediation. +func TestStackfileExportRootErrorNamesUnsupportedConstruct(t *testing.T) { + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1": + _, _ = w.Write([]byte(`{"id":"stack-1","name":"app","spec":{"stack_resources":[{"name":"web","init_spec":{"containers":[]},"source":{"image":{"ref":"nginx:alpine"}}}]}}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + cfg := &config.Config{ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", ProjectName: "proj-1", Insecure: true} + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + var stdout, stderr bytes.Buffer + code := runWithWriters([]string{"stackfile", "export", "app"}, &stdout, &stderr) + + if code == 0 { + t.Fatal("exit code = 0, want failure") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), `resource "web": init_spec`) { + t.Fatalf("stderr does not identify unsupported construct:\n%s", stderr.String()) + } +} + +// Conversion may fail after copying environment values into the candidate +// Stackfile. Arbitrary upstream errors can echo those values, so only known +// structural "not expressible" reasons may cross the process boundary. +func TestStackfileExportRootErrorDoesNotLeakEnvironmentValues(t *testing.T) { + const sensitiveValue = "credential-must-not-appear-in-errors" + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1": + _, _ = w.Write([]byte(`{ + "id":"stack-1", + "name":"app", + "spec":{ + "stack_resources":[ + {"name":"db","source":{"image":{"ref":"postgres:16"}}}, + { + "name":"web", + "source":{"image":{"ref":"nginx:alpine"}}, + "execution_config":{"environment_variables":[{"name":"DATABASE_URL","value":"` + sensitiveValue + `"}]} + } + ], + "connections":[{ + "kind":"env", + "from":{"type":"stack_resource","name":"db"}, + "to":{"type":"stack_resource","name":"web"}, + "mappings":[{"target":{"type":"env","name":"DATABASE_URL"},"value":{"output":"url"}}] + }] + } +}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + cfg := &config.Config{ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", ProjectName: "proj-1", Insecure: true} + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + var stdout, stderr bytes.Buffer + code := runWithWriters([]string{"stackfile", "export", "app", "-o", "json"}, &stdout, &stderr) + + if code == 0 { + t.Fatal("exit code = 0, want failure") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if strings.Contains(stderr.String(), sensitiveValue) { + t.Fatalf("stderr leaked an environment value: %s", stderr.String()) + } + var got struct { + Error string `json:"error"` + } + if err := json.Unmarshal(stderr.Bytes(), &got); err != nil { + t.Fatalf("stderr is not JSON: %v\n%s", err, stderr.String()) + } + if got.Error != "Cannot export stack as Stackfile" { + t.Fatalf("error = %q, want generic safe conversion failure", got.Error) + } +} + +func TestStackfileExportRestoresSecretAndPostgresNames(t *testing.T) { + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1": + _, _ = w.Write([]byte(`{ + "id":"stack-1", + "name":"app", + "spec":{ + "stack_resources":[{"name":"web","source":{"image":{"ref":"nginx:alpine"}}}], + "connections":[ + { + "kind":"env", + "from":{"type":"secret","id":"sec-1"}, + "to":{"type":"stack_resource","name":"web"}, + "mappings":[{"target":{"type":"env","name":"API_KEY"},"value":{"output":"token"}}] + }, + { + "kind":"env", + "from":{"type":"addon/postgres","id":"pg-1"}, + "to":{"type":"stack_resource","name":"web"}, + "mappings":[{"target":{"type":"env","name":"DATABASE_URL"},"value":{"output":"url"}}] + } + ] + } +}`)) + case "/api/v1/organizations/org-1/projects/proj-1/secrets": + _, _ = w.Write([]byte(`{"items":[{"id":"sec-1","name":"app-secrets","type":"Generic","data":[{"key":"token","value":"credential-must-not-leak"}]}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/addons/postgres": + _, _ = w.Write([]byte(`{"items":[{"id":"pg-1","name":"database","spec":{}}]}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfg := &config.Config{ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", ProjectName: "proj-1"} + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + cmd := newStackfileCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"export", "app", "-o", "json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("export resolved references: %v", err) + } + if strings.Contains(stdout.String(), "credential-must-not-leak") { + t.Fatalf("export leaked a secret credential: %s", stdout.String()) + } + + var exported struct { + Resources map[string]struct { + Secrets map[string]map[string]string `json:"secrets"` + Addons map[string]struct { + Type string `json:"type"` + Env map[string]string `json:"env"` + } `json:"addons"` + } `json:"resources"` + } + if err := json.Unmarshal(stdout.Bytes(), &exported); err != nil { + t.Fatalf("export output is not JSON: %v\n%s", err, stdout.String()) + } + web := exported.Resources["web"] + if got := web.Secrets["app-secrets"]["API_KEY"]; got != "token" { + t.Fatalf("secret mapping = %q, want token", got) + } + if addon := web.Addons["database"]; addon.Type != "postgres" || addon.Env["DATABASE_URL"] != "{{ url }}" { + t.Fatalf("postgres addon = %+v, want database/url mapping", addon) + } +} + +func TestStackfileExportFailsWhenSecretIDCannotBeResolved(t *testing.T) { + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1": + _, _ = w.Write([]byte(`{"id":"stack-1","name":"app","spec":{"stack_resources":[{"name":"web","source":{"image":{"ref":"nginx:alpine"}}}],"connections":[{"kind":"env","from":{"type":"secret","id":"sec-missing"},"to":{"type":"stack_resource","name":"web"},"mappings":[{"target":{"type":"env","name":"API_KEY"},"value":{"output":"token"}}]}]}}`)) + case "/api/v1/organizations/org-1/projects/proj-1/secrets": + _, _ = w.Write([]byte(`{"items":[]}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfg := &config.Config{ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", ProjectName: "proj-1"} + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + cmd := newStackfileCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"export", "app"}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "sec-missing") { + t.Fatalf("export error = %v, want unresolved secret ID", err) + } +} + +func TestStackfileExportRejectsEffectiveNonDefaultSettings(t *testing.T) { + tests := []struct { + name string + settings string + want string + }{ + { + name: "release retention", + settings: `{"release_retention_limit":20,"min_successful_releases":5}`, + want: "release_retention_limit", + }, + { + name: "minimum successful releases", + settings: `{"release_retention_limit":10,"min_successful_releases":2}`, + want: "min_successful_releases", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1": + _, _ = fmt.Fprintf(w, `{"id":"stack-1","name":"app","settings":%s,"spec":{"stack_resources":[{"name":"web","source":{"image":{"ref":"nginx:alpine"}}}]}}`, tt.settings) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfg := &config.Config{ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", ProjectName: "proj-1"} + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + cmd := newStackfileCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"export", "app"}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("export error = %v, want unsupported %s setting", err, tt.want) + } + }) + } +} diff --git a/cmd/stackdome/status.go b/cmd/stackdome/status.go index 51a3cfc..2ce63f0 100644 --- a/cmd/stackdome/status.go +++ b/cmd/stackdome/status.go @@ -4,11 +4,17 @@ import ( "os" "time" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/output" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/cmdutil" - "github.com/stackdome/cli/internal/output" ) +type statusResult struct { + Stack any `json:"stack" yaml:"stack"` + LiveStatus any `json:"live_status" yaml:"live_status"` +} + func newStatusCmd() *cobra.Command { var ( flagWatch bool @@ -20,6 +26,11 @@ func newStatusCmd() *cobra.Command { Use: "status [resource]", Short: "Show stack and resource status", 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 { + return err + } + } stackID, err := resolveStackID(ctx, cmd, flagStack) if err != nil { return err @@ -33,16 +44,15 @@ func newStatusCmd() *cobra.Command { if err != nil { return err } - - if !ctx.Formatter.IsTable() { - return ctx.Formatter.PrintStructured(stack) - } - live, err := ctx.Client.GetStackLiveStatus(cmd.Context(), stack) if err != nil { return err } + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(statusResult{Stack: stack, LiveStatus: live}) + } + output.RenderStackStatus(os.Stdout, stack, live, flagConditions) return nil })), @@ -60,23 +70,35 @@ func watchStatus(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID string defer tick.Stop() for { + if cmd.Context().Err() != nil { + return clierrors.ErrUserCanceled + } stack, err := ctx.Client.GetStack(cmd.Context(), stackID) if err != nil { + if cmd.Context().Err() != nil { + return clierrors.ErrUserCanceled + } + return err + } + live, err := ctx.Client.GetStackLiveStatus(cmd.Context(), stack) + if err != nil { + if cmd.Context().Err() != nil { + return clierrors.ErrUserCanceled + } return err } // Structured mode emits one object per tick — no redraw, no escape // codes, so `status -w -o json` stays parseable as it streams. - if !ctx.Formatter.IsTable() { - if err := ctx.Formatter.PrintStructured(stack); err != nil { + if ctx.Formatter.Format == output.FormatJSON { + if err := ctx.Formatter.PrintJSONLine(statusResult{Stack: stack, LiveStatus: live}); err != nil { return err } - } else { - live, err := ctx.Client.GetStackLiveStatus(cmd.Context(), stack) - if err != nil { + } else if !ctx.Formatter.IsTable() { + if err := ctx.Formatter.PrintStructured(statusResult{Stack: stack, LiveStatus: live}); err != nil { return err } - + } else { // Clear screen — only meaningful on a terminal; escape codes would // otherwise corrupt piped/redirected output. if output.IsTTY() { @@ -87,7 +109,7 @@ func watchStatus(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID string select { case <-cmd.Context().Done(): - return nil + return clierrors.ErrUserCanceled case <-tick.C: } } diff --git a/cmd/stackdome/status_test.go b/cmd/stackdome/status_test.go new file mode 100644 index 0000000..040e175 --- /dev/null +++ b/cmd/stackdome/status_test.go @@ -0,0 +1,212 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + "github.com/Stackdome/stackdome-cli/internal/config" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/output" + "gopkg.in/yaml.v3" +) + +func TestStatusWatchRejectsYAMLStreamOutput(t *testing.T) { + const stackID = "b02262ac-8e6e-45cd-b18e-acb5d3f97ce4" + requests := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"` + stackID + `","name":"app","spec":{}}`)) + })) + defer ts.Close() + + ctx := cmdutil.NewCommandContext(&config.Config{ServerURL: ts.URL, AccessToken: "sdm_test", OrganizationID: "org-1", ProjectName: "proj-1", CurrentStack: stackID}, output.FormatYAML, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + commandContext, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + cmd := newStatusCmd() + cmd.SetContext(commandContext) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--watch"}) + + err := cmd.Execute() + var cliErr *clierrors.CLIError + if !errors.As(err, &cliErr) || cliErr.Code != "VALIDATION_ERROR" { + t.Fatalf("status --watch YAML error = %T (%v), want validation error", err, err) + } + if stdout.Len() != 0 || requests != 0 { + t.Errorf("stdout = %q, requests = %d; want rejection before watching", stdout.String(), requests) + } +} + +// Status must expose the persisted stack separately from the dynamic live +// status, since the stack document alone cannot tell an agent what is serving. +func TestStatusJSONIncludesStackAndLiveStatus(t *testing.T) { + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1": + _, _ = w.Write([]byte(`{"id":"stack-1","name":"app","spec":{},"converged_release":{"id":"rel-7"}}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases/rel-7": + _, _ = w.Write([]byte(`{"id":"rel-7","live_status":{"health":"ok"}}`)) + 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"}, 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{"--stack", "app"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("status: %v", err) + } + + var got struct { + Stack json.RawMessage `json:"stack"` + LiveStatus struct { + Health string `json:"health"` + } `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) == 0 { + t.Errorf("result omitted stack: %s", stdout.String()) + } + if got.LiveStatus.Health != "ok" { + t.Errorf("live_status.health = %q, want ok", got.LiveStatus.Health) + } +} + +func TestStatusYAMLIncludesStackAndLiveStatus(t *testing.T) { + 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": + _, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"app","spec":{}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1": + _, _ = w.Write([]byte(`{"id":"stack-1","name":"app","spec":{},"converged_release":{"id":"rel-7"}}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases/rel-7": + _, _ = w.Write([]byte(`{"id":"rel-7","live_status":{"health":"ok"}}`)) + 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"}, output.FormatYAML, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + cmd := newStatusCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--stack", "app"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("status: %v", err) + } + + var got struct { + Stack map[string]any `yaml:"stack"` + Live struct { + Health string `yaml:"health"` + } `yaml:"live_status"` + } + if err := yaml.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("status output is not YAML: %v\nstdout: %s", err, stdout.String()) + } + if got.Stack["name"] != "app" || got.Live.Health != "ok" { + t.Errorf("YAML result = %#v, want stack app and live health ok", got) + } +} + +// 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) { + commandContext, cancel := context.WithCancel(context.Background()) + defer cancel() + releaseReads := 0 + 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/stack-1": + _, _ = w.Write([]byte(`{"id":"stack-1","name":"app","spec":{},"converged_release":{"id":"rel-7"}}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases/rel-7": + releaseReads++ + health := "ok" + if releaseReads == 2 { + health = "progressing" + time.AfterFunc(10*time.Millisecond, cancel) + } + _, _ = w.Write([]byte(`{"id":"rel-7","live_status":{"health":"` + health + `"}}`)) + 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"}, output.FormatJSON, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + cmd := newStatusCmd() + cmd.SetContext(commandContext) + err := watchStatus(ctx, cmd, "stack-1", false) + if err != clierrors.ErrUserCanceled { + t.Fatalf("watchStatus error = %v, want cancellation", err) + } + + stream := stdout.String() + if len(stream) == 0 || stream[len(stream)-1] != '\n' { + t.Fatalf("watch output does not end on a line: %q", stream) + } + if bytes.Contains([]byte(stream), []byte("\n ")) { + t.Fatalf("watch output is indented rather than compact: %q", stream) + } + var lines []struct { + Stack json.RawMessage `json:"stack"` + LiveStatus struct { + Health string `json:"health"` + } `json:"live_status"` + } + scanner := bufio.NewScanner(bytes.NewReader(stdout.Bytes())) + for scanner.Scan() { + var line struct { + Stack json.RawMessage `json:"stack"` + LiveStatus struct { + Health string `json:"health"` + } `json:"live_status"` + } + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { + t.Fatalf("watch line is not independent JSON: %v\nline: %s", err, scanner.Text()) + } + lines = append(lines, line) + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan watch output: %v", err) + } + if len(lines) != 2 { + t.Fatalf("decoded %d lines, want 2: %s", len(lines), stream) + } + if len(lines[0].Stack) == 0 || lines[0].LiveStatus.Health != "ok" || len(lines[1].Stack) == 0 || lines[1].LiveStatus.Health != "progressing" { + t.Errorf("watch lines = %#v, want ok then progressing live status", lines) + } +} diff --git a/cmd/stackdome/token.go b/cmd/stackdome/token.go index 908a7dc..227fa57 100644 --- a/cmd/stackdome/token.go +++ b/cmd/stackdome/token.go @@ -6,10 +6,10 @@ import ( "strings" "time" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/output" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/cmdutil" - clierrors "github.com/stackdome/cli/internal/errors" - "github.com/stackdome/cli/internal/output" ) func newTokenCmd() *cobra.Command { @@ -126,8 +126,11 @@ func newTokenDeleteCmd() *cobra.Command { return err } - fmt.Fprintf(os.Stderr, "API token %q deleted.\n", args[0]) - return nil + return printMutationResult(ctx, mutationResult{ + Status: "deleted", + Resource: "api_token", + ID: args[0], + }, fmt.Sprintf("API token %q deleted.", args[0])) })), } diff --git a/cmd/stackdome/validate.go b/cmd/stackdome/validate.go index b766a96..263f031 100644 --- a/cmd/stackdome/validate.go +++ b/cmd/stackdome/validate.go @@ -4,8 +4,10 @@ import ( "fmt" "os" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/stackfile" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/stackfile" ) func newValidateCmd() *cobra.Command { @@ -13,18 +15,28 @@ func newValidateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "validate", - Short: "Validate a stackfile", - RunE: func(cmd *cobra.Command, args []string) error { - _, err := stackfile.Load(flagFile) + Short: "Validate a Stackfile", + RunE: cmdutil.WithContext(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + sf, err := stackfile.Load(flagFile) if err != nil { return err } + if _, err := sf.ToStack(); err != nil { + return clierrors.ValidationError(err.Error()) + } + result := struct { + Valid bool `json:"valid"` + File string `json:"file"` + }{Valid: true, File: flagFile} + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(result) + } fmt.Fprintf(os.Stderr, "Stackfile %q is valid.\n", flagFile) return nil - }, + }), } - cmd.Flags().StringVarP(&flagFile, "file", "f", "stackfile.yaml", "Path to stackfile") + cmd.Flags().StringVarP(&flagFile, "file", "f", "stackfile.yaml", "Path to Stackfile") return cmd } diff --git a/cmd/stackdome/validate_test.go b/cmd/stackdome/validate_test.go new file mode 100644 index 0000000..87f8e83 --- /dev/null +++ b/cmd/stackdome/validate_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + "github.com/Stackdome/stackdome-cli/internal/config" + "github.com/Stackdome/stackdome-cli/internal/output" +) + +func TestValidatePrintsJSONSuccess(t *testing.T) { + path := filepath.Join(t.TempDir(), "stackfile.yaml") + if err := os.WriteFile(path, []byte("name: demo\nresources:\n web:\n image: nginx:alpine\n"), 0o644); err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + ctx := cmdutil.NewCommandContext(&config.Config{}, output.FormatJSON, slog.LevelError) + ctx.Formatter.Writer = &stdout + cmd := newValidateCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--file", path}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("validate: %v", err) + } + var result struct { + Valid bool `json:"valid"` + File string `json:"file"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("validate output is not JSON: %v\n%s", err, stdout.String()) + } + if !result.Valid || result.File != path { + t.Fatalf("result = %+v, want valid file %q", result, path) + } +} + +func TestValidateReturnsErrorForInvalidStackfile(t *testing.T) { + path := filepath.Join(t.TempDir(), "stackfile.yaml") + if err := os.WriteFile(path, []byte("name: demo\nresources: {}\n"), 0o644); err != nil { + t.Fatal(err) + } + ctx := cmdutil.NewCommandContext(&config.Config{}, output.FormatJSON, slog.LevelError) + ctx.Formatter.Writer = &bytes.Buffer{} + cmd := newValidateCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--file", path}) + + if err := cmd.Execute(); err == nil { + t.Fatal("expected invalid Stackfile to return an error") + } +} + +func TestValidateRejectsStackfileThatCannotConvertForDeploy(t *testing.T) { + path := filepath.Join(t.TempDir(), "stackfile.yaml") + content := `name: demo +resources: + web: + build: + repo: https://github.com/example/app.git + branch: main + git_secret: legacy-git-credentials +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + ctx := cmdutil.NewCommandContext(&config.Config{}, output.FormatJSON, slog.LevelError) + ctx.Formatter.Writer = &bytes.Buffer{} + cmd := newValidateCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--file", path}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "git_secret") { + t.Fatalf("validate error = %v, want unsupported git_secret error", err) + } +} diff --git a/cmd/stackdome/version.go b/cmd/stackdome/version.go index 3724fce..8b3fb6d 100644 --- a/cmd/stackdome/version.go +++ b/cmd/stackdome/version.go @@ -3,8 +3,8 @@ package main import ( "runtime" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/cmdutil" ) var ( diff --git a/cmd/stackdome/volume.go b/cmd/stackdome/volume.go index 55ef197..a92a748 100644 --- a/cmd/stackdome/volume.go +++ b/cmd/stackdome/volume.go @@ -4,10 +4,10 @@ import ( "fmt" "os" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" openapi "github.com/Stackdome/stackdome/pkg/api/openapi" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/cmdutil" - clierrors "github.com/stackdome/cli/internal/errors" ) func newVolumeCmd() *cobra.Command { @@ -70,18 +70,23 @@ func newVolumeCreateCmd() *cobra.Command { var ( flagSize string flagAccessMode string + flagStack string ) cmd := &cobra.Command{ Use: "create ", - Short: "Create a volume", + Short: "Create a volume in the current stack", Args: cobra.ExactArgs(1), RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { if flagSize == "" { return clierrors.ValidationError("--size is required (e.g. 5Gi)") } + stackID, err := resolveStackID(ctx, cmd, flagStack) + if err != nil { + return err + } - volume, err := ctx.Client.CreateVolume(cmd.Context(), args[0], flagSize, flagAccessMode) + volume, err := ctx.Client.CreateVolume(cmd.Context(), stackID, args[0], flagSize, flagAccessMode) if err != nil { return err } @@ -97,6 +102,7 @@ func newVolumeCreateCmd() *cobra.Command { cmd.Flags().StringVar(&flagSize, "size", "", "Volume size (e.g. 5Gi)") cmd.Flags().StringVar(&flagAccessMode, "access-mode", "ReadWriteOnce", "Access mode (ReadWriteOnce, ReadWriteMany, ReadOnlyMany)") + cmd.Flags().StringVarP(&flagStack, "stack", "s", "", "Stack name (overrides current context)") return cmd } @@ -132,8 +138,12 @@ func newVolumeDeleteCmd() *cobra.Command { return err } - fmt.Fprintf(os.Stderr, "Volume %q deleted.\n", args[0]) - return nil + return printMutationResult(ctx, mutationResult{ + Status: "deleted", + Resource: "volume", + Name: args[0], + ID: volume.GetId(), + }, fmt.Sprintf("Volume %q deleted.", args[0])) })), } diff --git a/cmd/stackdome/volume_test.go b/cmd/stackdome/volume_test.go new file mode 100644 index 0000000..779e0c2 --- /dev/null +++ b/cmd/stackdome/volume_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Stackdome/stackdome-cli/internal/cmdutil" + "github.com/Stackdome/stackdome-cli/internal/config" + "github.com/Stackdome/stackdome-cli/internal/output" +) + +func TestVolumeCreateUsesCurrentStackEndpoint(t *testing.T) { + const stackID = "11111111-1111-1111-1111-111111111111" + var requestedPath string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/organizations/org-1/projects/proj-1/stacks/"+stackID+"/volumes" { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"id":"volume-1","name":"test-cli","spec":{"size":"1Gi","access_mode":"ReadWriteOnce"}}`)) + })) + defer ts.Close() + + cfg := &config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "proj-1", + CurrentStack: stackID, + } + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + + cmd := newVolumeCreateCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"test-cli", "--size", "1Gi"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("volume create: %v; requested path: %s", err, requestedPath) + } + + var got struct { + Name string `json:"name"` + Spec struct { + Size string `json:"size"` + } `json:"spec"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("stdout is not volume JSON: %v\n%s", err, stdout.String()) + } + if got.Name != "test-cli" || got.Spec.Size != "1Gi" { + t.Fatalf("volume = %#v, want test-cli/1Gi", got) + } +} + +func TestVolumeCreateStackFlagOverridesCurrentContext(t *testing.T) { + const targetStackID = "22222222-2222-2222-2222-222222222222" + var postedPath string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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":"` + targetStackID + `","name":"target","spec":{}}],"total":1}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/"+targetStackID+"/volumes": + postedPath = r.URL.Path + _, _ = w.Write([]byte(`{"id":"volume-1","name":"test-cli","spec":{"size":"1Gi","access_mode":"ReadWriteOnce"}}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfg := &config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "proj-1", + CurrentStack: "11111111-1111-1111-1111-111111111111", + } + ctx := cmdutil.NewCommandContext(cfg, output.FormatJSON, slog.LevelError) + ctx.Formatter.Writer = &bytes.Buffer{} + + cmd := newVolumeCreateCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"test-cli", "--size", "1Gi", "--stack", "target"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("volume create with --stack: %v", err) + } + wantPath := "/api/v1/organizations/org-1/projects/proj-1/stacks/" + targetStackID + "/volumes" + if postedPath != wantPath { + t.Fatalf("POST path = %q, want %q", postedPath, wantPath) + } +} + +func TestVolumeCreateHelpExplainsCurrentStackScope(t *testing.T) { + cmd := newVolumeCreateCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"--help"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("volume create help: %v", err) + } + if !bytes.Contains(stdout.Bytes(), []byte("Create a volume in the current stack")) { + t.Fatalf("help does not explain current-stack scope:\n%s", stdout.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte("--stack")) { + t.Fatalf("help does not show stack override:\n%s", stdout.String()) + } +} diff --git a/cmd/stackdome/wait.go b/cmd/stackdome/wait.go new file mode 100644 index 0000000..84e1d30 --- /dev/null +++ b/cmd/stackdome/wait.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "time" + + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" +) + +// defaultWaitTimeout bounds commands that follow asynchronous server work. +// Callers may provide a positive timeout to override it. +const defaultWaitTimeout = 10 * time.Minute + +func waitContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + if timeout <= 0 { + timeout = defaultWaitTimeout + } + return context.WithTimeout(parent, timeout) +} + +// waitCommandError gives the entire wait-and-observe sequence one stable +// cancellation contract. The child context represents the command timeout; +// an interrupted parent remains the user-cancellation contract. +func waitCommandError(parent, wait context.Context, err error) error { + if parent.Err() != nil { + return clierrors.ErrUserCanceled + } + if wait.Err() == context.DeadlineExceeded { + return clierrors.New("Timed out waiting for the release to finish.").WithCode("TIMEOUT") + } + return err +} diff --git a/cmd/stackdome/wait_test.go b/cmd/stackdome/wait_test.go new file mode 100644 index 0000000..f5c86d4 --- /dev/null +++ b/cmd/stackdome/wait_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "context" + "testing" + "time" +) + +func TestWaitContextUsesDefaultForNonPositiveTimeout(t *testing.T) { + before := time.Now() + ctx, cancel := waitContext(context.Background(), 0) + defer cancel() + + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("wait context has no deadline") + } + if delta := deadline.Sub(before); delta < defaultWaitTimeout-time.Second || delta > defaultWaitTimeout+time.Second { + t.Errorf("deadline offset = %s, want about %s", delta, defaultWaitTimeout) + } +} + +func TestWaitContextHonorsExplicitTimeout(t *testing.T) { + before := time.Now() + ctx, cancel := waitContext(context.Background(), 25*time.Millisecond) + defer cancel() + + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("wait context has no deadline") + } + if delta := deadline.Sub(before); delta < 20*time.Millisecond || delta > 100*time.Millisecond { + t.Errorf("deadline offset = %s, want about 25ms", delta) + } +} diff --git a/cmd/stackdome/whoami.go b/cmd/stackdome/whoami.go index de1172b..d7c88d7 100644 --- a/cmd/stackdome/whoami.go +++ b/cmd/stackdome/whoami.go @@ -3,8 +3,8 @@ package main import ( "strings" + "github.com/Stackdome/stackdome-cli/internal/cmdutil" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/cmdutil" ) type whoamiInfo struct { diff --git a/config/stackfile_schema.yaml b/config/stackfile_schema.yaml deleted file mode 100644 index a190daf..0000000 --- a/config/stackfile_schema.yaml +++ /dev/null @@ -1,288 +0,0 @@ -openapi: 3.0.0 -info: - title: Stackdome Stackfile Schema - version: 1.0.0 - description: > - Schema for the Stackdome stackfile format. A stackfile is a developer-friendly - YAML manifest that defines a stack (multi-service deployment) for the Stackdome - platform. The CLI parses stackfiles and converts them into the Stack API format. - -components: - schemas: - Stackfile: - type: object - required: - - name - - resources - additionalProperties: false - properties: - name: - type: string - minLength: 1 - description: Stack name. Must be unique within the team/project. - example: infisical - resources: - type: object - description: > - Map of resource name to resource definition. Each resource is a - containerized service within the stack. At least one resource is required. - additionalProperties: - $ref: '#/components/schemas/Resource' - minProperties: 1 - volumes: - type: object - description: > - Map of volume name to volume definition. Volumes provide persistent - storage that can be mounted into resources. - additionalProperties: - $ref: '#/components/schemas/VolumeDef' - - Resource: - type: object - description: > - A containerized service within the stack. Must have either `image` (pre-built - container image) or `build` (build from source), but not both. - - Mutual exclusion: exactly one of `image` or `build` must be provided. - This is enforced via oneOf with two variants. - additionalProperties: false - oneOf: - - required: [image] - not: - required: [build] - - required: [build] - not: - required: [image] - properties: - image: - type: string - minLength: 1 - description: Container image reference. Mutually exclusive with `build`. - example: postgres:14-alpine - build: - $ref: '#/components/schemas/BuildConfig' - ports: - type: array - items: - $ref: '#/components/schemas/PortDef' - env: - type: object - description: > - Environment variables for the resource. Values can be: - - - Literal strings: `KEY: "value"` - - Self output references: `KEY: "{{ self. }}"` — reads from this resource's own outputs - - Resource references: `KEY: "{{ . }}"` — reads from another resource's outputs, auto-generates a connection - - Embedded templates: `KEY: "postgres://user:pass@{{ db.host }}:5432/mydb"` — generates a template connection - - Available stack resource outputs: - - `host` — internal service hostname - - `port.` — port number (e.g., `port.postgres`) - - `url.` — internal URL - - `public..host` — public hostname (only if port is public) - - `public..url` — public URL (only if port is public) - additionalProperties: - type: string - secrets: - type: object - description: > - Map of secret name to env var mappings. Each key is a Stackdome secret - name, and the value maps environment variable names to secret keys. - The CLI resolves secret names to IDs at deploy time. - additionalProperties: - $ref: '#/components/schemas/SecretMapping' - addons: - type: object - description: > - Map of addon name to connection config. Each key is a Stackdome addon - name, and the value configures how the addon's outputs are injected. - The CLI resolves addon names to IDs at deploy time. - additionalProperties: - $ref: '#/components/schemas/AddonConnectionConfig' - volumes: - type: array - description: Volumes to mount into this resource. Each referenced volume must be defined in the top-level `volumes` section. - items: - $ref: '#/components/schemas/VolumeMountDef' - depends_on: - type: array - description: List of resource names this resource depends on for startup ordering. - items: - type: string - minLength: 1 - stateful: - type: boolean - description: Mark this resource as stateful (e.g., databases). Default false. - default: false - - BuildConfig: - type: object - description: > - Build a container image from source. Mutually exclusive with `image`. - - Revision: at most one of `branch`, `tag`, or `commit` may be specified. - If none is set, defaults to branch "main". - required: - - repo - additionalProperties: false - properties: - repo: - type: string - minLength: 1 - description: Git repository URL. - example: https://github.com/myorg/myapp.git - branch: - type: string - minLength: 1 - description: Git branch to build from. Mutually exclusive with `tag` and `commit`. Default "main". - example: main - tag: - type: string - minLength: 1 - description: Git tag to build from. Mutually exclusive with `branch` and `commit`. - example: v1.0.0 - commit: - type: string - minLength: 1 - description: Git commit SHA to build from. Mutually exclusive with `branch` and `tag`. - dockerfile: - type: string - minLength: 1 - description: Path to Dockerfile relative to context. Default "Dockerfile". - default: Dockerfile - context: - type: string - minLength: 1 - description: Build context path within the repository. Default ".". - default: "." - # At most one of branch, tag, commit. OpenAPI 3.0 cannot express - # "at most one of" directly; validated in code. - - PortDef: - type: object - required: - - name - - port - additionalProperties: false - properties: - name: - type: string - minLength: 1 - description: > - Port name. Used in output accessors (e.g., a port named "http" produces - outputs `port.http`, `url.http`, and if public, `public.http.url`). - example: http - port: - type: integer - minimum: 1 - maximum: 65535 - description: Port number. - example: 8080 - protocol: - type: string - description: Protocol. Default "HTTP". - enum: - - HTTP - - TCP - default: HTTP - public: - type: boolean - description: Expose this port publicly via ingress. Default false. - default: false - subdomain: - type: string - minLength: 1 - description: Subdomain prefix for the public URL. Only applicable when `public` is true. - example: api - - VolumeDef: - type: object - required: - - size - additionalProperties: false - properties: - size: - type: string - minLength: 1 - pattern: '^\d+(Ki|Mi|Gi|Ti|Pi|Ei)?$' - description: > - Volume size in Kubernetes resource quantity format (e.g., "1Gi", "500Mi", "10Gi"). - example: 5Gi - access_mode: - type: string - description: Volume access mode. Default "ReadWriteOnce". - enum: - - ReadWriteOnce - - ReadWriteMany - - ReadOnlyMany - default: ReadWriteOnce - - VolumeMountDef: - type: object - required: - - name - - path - additionalProperties: false - properties: - name: - type: string - minLength: 1 - description: Name of a volume defined in the top-level `volumes` section. - path: - type: string - minLength: 1 - pattern: '^/' - description: Absolute path where the volume is mounted in the container. - example: /var/lib/postgresql/data - - SecretMapping: - type: object - description: > - Maps environment variable names to secret keys. Each key is the env var - name to set, and each value is the key within the Stackdome secret. - additionalProperties: - type: string - minLength: 1 - minProperties: 1 - example: - API_KEY: api_key - API_SECRET: api_secret - - AddonConnectionConfig: - type: object - required: - - type - - env - additionalProperties: false - description: > - Connection config for an addon. The `type` field determines which - addon-specific fields are available. - properties: - type: - type: string - description: Addon type. - enum: - - postgres - env: - type: object - description: > - Maps environment variable names to addon output accessors or templates. - - For postgres addons, available outputs are: - `host`, `port`, `database`, `username`, `password`, `sslmode`, - `ca_certificate`, `url`. - - Values can be: - - Direct output: `DB_HOST: host` - - Template: `DATABASE_URL: "postgres://{{ username }}:{{ password }}@{{ host }}:{{ port }}/{{ database }}"` - additionalProperties: - type: string - minProperties: 1 - database: - type: string - minLength: 1 - description: Target database name within the postgres addon. - superuser: - type: boolean - description: Use superuser credentials. Default false. - default: false diff --git a/go.mod b/go.mod index a089aa6..acdb998 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,9 @@ -module github.com/stackdome/cli +module github.com/Stackdome/stackdome-cli go 1.25.0 require ( - github.com/Stackdome/stackdome v0.0.1-alpha.0.20260805180415-8ce931c66403 + github.com/Stackdome/stackdome v0.0.1-alpha.0.20260807222415-fff668b59ddc github.com/charmbracelet/lipgloss v1.1.0 github.com/joho/godotenv v1.5.1 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 829f2b2..1dbb3f6 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Stackdome/stackdome v0.0.1-alpha.0.20260805180415-8ce931c66403 h1:oqV8D0bSJTAam7gFm8BFt0V+RTsrPL0RfqHB0RUA/hs= -github.com/Stackdome/stackdome v0.0.1-alpha.0.20260805180415-8ce931c66403/go.mod h1:domgN80PtgYKxEH5WIqxMQfNAzzc2tN0IaR5AEou2r4= +github.com/Stackdome/stackdome v0.0.1-alpha.0.20260807222415-fff668b59ddc h1:jASfbjh9Vxm2ZA1u1GmQ1LkT/sZuHywzOA2CGS7NKPU= +github.com/Stackdome/stackdome v0.0.1-alpha.0.20260807222415-fff668b59ddc/go.mod h1:cEp3rnEHbeTCEt7WRBFrWe7dqci+3+uU4ihQ/IUtXUc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= @@ -80,6 +80,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= diff --git a/internal/client/api.go b/internal/client/api.go new file mode 100644 index 0000000..32d192f --- /dev/null +++ b/internal/client/api.go @@ -0,0 +1,122 @@ +package client + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + pathpkg "path" + "strings" +) + +// APIResponse is a buffered response from a direct API request. +type APIResponse struct { + StatusCode int + Header http.Header + Body []byte +} + +// APIRequest sends an authenticated request to a relative /api/ path through +// the client's refresh-aware transport. Redirects may stay on the configured +// origin, but are rejected before a request can leave it. +func (c *Client) APIRequest(ctx context.Context, method, path string, headers http.Header, body []byte) (APIResponse, error) { + base, err := url.Parse(c.baseURL) + if err != nil || base.Scheme == "" || base.Host == "" { + return APIResponse{}, fmt.Errorf("invalid configured server URL") + } + + target, err := parseAPIPath(path) + if err != nil { + return APIResponse{}, err + } + + req, err := http.NewRequestWithContext(ctx, method, base.ResolveReference(target).String(), bytes.NewReader(body)) + if err != nil { + return APIResponse{}, fmt.Errorf("create API request: %w", err) + } + if headers != nil { + req.Header = headers.Clone() + } + if c.accessToken != "" { + req.Header.Set("Authorization", "Bearer "+c.accessToken) + } + + resp, err := c.cfg.HTTPClient.Do(req) + if err != nil { + return APIResponse{}, err + } + defer resp.Body.Close() + + response := APIResponse{StatusCode: resp.StatusCode, Header: resp.Header.Clone()} + response.Body, err = io.ReadAll(resp.Body) + if err != nil { + return response, err + } + return response, nil +} + +func parseAPIPath(raw string) (*url.URL, error) { + target, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid API path: %w", err) + } + if target.IsAbs() || target.Host != "" || target.Fragment != "" || !strings.HasPrefix(target.Path, "/api/") || !isWithinAPIPath(target.Path) { + return nil, fmt.Errorf("API path must be a relative path beginning with /api/") + } + return target, nil +} + +// ValidateAPIPath reports whether raw is a safe direct API path. It is +// exported so the command can reject an unsafe path before constructing a +// client request while retaining the client's defense in depth. +func ValidateAPIPath(raw string) error { + _, err := parseAPIPath(raw) + return err +} + +func isWithinAPIPath(requestPath string) bool { + for { + cleaned := pathpkg.Clean(requestPath) + if cleaned != "/api" && !strings.HasPrefix(cleaned, "/api/") { + return false + } + decoded, err := url.PathUnescape(requestPath) + if err != nil || decoded == requestPath { + return err == nil + } + requestPath = decoded + } +} + +func sameOriginRedirectPolicy(base *url.URL) func(*http.Request, []*http.Request) error { + return func(redirect *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + if base == nil || redirect.URL.User != nil || !sameAPIOrigin(base, redirect.URL) { + return fmt.Errorf("refusing redirect to a different origin") + } + return nil + } +} + +func sameAPIOrigin(a, b *url.URL) bool { + return strings.EqualFold(a.Scheme, b.Scheme) && canonicalHostPort(a) == canonicalHostPort(b) +} + +func canonicalHostPort(u *url.URL) string { + host := strings.ToLower(u.Hostname()) + port := u.Port() + if port == "" { + switch strings.ToLower(u.Scheme) { + case "http": + port = "80" + case "https": + port = "443" + } + } + return net.JoinHostPort(host, port) +} diff --git a/internal/client/api_test.go b/internal/client/api_test.go new file mode 100644 index 0000000..9b7ef7c --- /dev/null +++ b/internal/client/api_test.go @@ -0,0 +1,278 @@ +package client + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// Removing the direct request's configured transport or bearer header would +// prevent an authenticated API escape hatch from reaching the selected server. +func TestAPIRequestUsesConfiguredServerAndBearerToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.URL.RequestURI(), "/api/v1/users/current?verbose=true"; got != want { + t.Errorf("request URI = %q, want %q", got, want) + } + if got, want := r.Header.Get("Authorization"), "Bearer sdm_test"; got != want { + t.Errorf("Authorization = %q, want %q", got, want) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"user-1"}`)) + })) + defer server.Close() + + c := New(server.URL, WithTokens("sdm_test", "")) + response, err := c.APIRequest(context.Background(), http.MethodGet, "/api/v1/users/current?verbose=true", nil, nil) + if err != nil { + t.Fatalf("APIRequest: %v", err) + } + if response.StatusCode != http.StatusOK { + t.Errorf("status = %d, want %d", response.StatusCode, http.StatusOK) + } + if got, want := string(response.Body), `{"id":"user-1"}`; got != want { + t.Errorf("body = %q, want %q", got, want) + } +} + +// The direct API request must use the normal refresh-aware transport rather +// than treating a 401 as a terminal response. +func TestAPIRequestRefreshesAfter401(t *testing.T) { + protectedHits := 0 + refreshHits := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/protected": + protectedHits++ + if got := r.Header.Get("Authorization"); protectedHits == 1 && got != "Bearer access-old" { + t.Errorf("initial Authorization = %q, want old token", got) + } + if protectedHits == 1 { + w.WriteHeader(http.StatusUnauthorized) + return + } + if got, want := r.Header.Get("Authorization"), "Bearer access-new"; got != want { + t.Errorf("retried Authorization = %q, want %q", got, want) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/api/v1/auth/refresh": + refreshHits++ + var body struct { + RefreshToken string `json:"refreshToken"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode refresh body: %v", err) + } + if got, want := body.RefreshToken, "refresh-old"; got != want { + t.Errorf("refresh token = %q, want %q", got, want) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"access-new","refreshToken":"refresh-new"}`)) + default: + t.Errorf("unexpected request: %s", r.URL) + } + })) + defer server.Close() + + c := New(server.URL, WithTokens("access-old", "refresh-old")) + response, err := c.APIRequest(context.Background(), http.MethodGet, "/api/v1/protected", nil, nil) + if err != nil { + t.Fatalf("APIRequest: %v", err) + } + if response.StatusCode != http.StatusOK || string(response.Body) != `{"ok":true}` { + t.Errorf("response = %#v, want refreshed 200 JSON response", response) + } + if protectedHits != 2 || refreshHits != 1 { + t.Errorf("protected hits = %d, refresh hits = %d; want 2 and 1", protectedHits, refreshHits) + } +} + +// A refresh request carries the long-lived refresh token. A 307/308 must not +// replay that POST to a foreign origin after an API request receives a 401. +func TestAPIRequestRejectsCrossOriginRefreshRedirect(t *testing.T) { + for _, status := range []int{http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + t.Run(http.StatusText(status), func(t *testing.T) { + foreignRequests := 0 + foreignBody := "" + foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + foreignRequests++ + body, _ := io.ReadAll(r.Body) + foreignBody = string(body) + })) + defer foreign.Close() + + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/protected": + w.WriteHeader(http.StatusUnauthorized) + case "/api/v1/auth/refresh": + w.Header().Set("Location", foreign.URL+"/refresh") + w.WriteHeader(status) + default: + t.Errorf("unexpected request: %s", r.URL) + } + })) + defer origin.Close() + + c := New(origin.URL, WithTokens("access-old", "refresh-secret")) + response, err := c.APIRequest(context.Background(), http.MethodGet, "/api/v1/protected", nil, nil) + if err != nil { + t.Fatalf("APIRequest: %v", err) + } + if response.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want original 401", response.StatusCode) + } + if foreignRequests != 0 || foreignBody != "" { + t.Errorf("foreign requests = %d, body = %q; want no refresh replay", foreignRequests, foreignBody) + } + }) + } +} + +// A custom redirect policy must retain a bounded same-origin chain rather +// than allowing an attacker-controlled loop until the request timeout. +func TestAPIRequestLimitsSameOriginRedirects(t *testing.T) { + hits := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + if hits <= 11 { + http.Redirect(w, r, "/api/v1/redirect", http.StatusFound) + return + } + _, _ = w.Write([]byte(`{"unexpected":"success"}`)) + })) + defer server.Close() + + c := New(server.URL, WithTokens("sdm_test", "")) + _, err := c.APIRequest(context.Background(), http.MethodGet, "/api/v1/redirect", nil, nil) + if err == nil { + t.Fatal("APIRequest error = nil, want redirect limit error") + } + if hits > 10 { + t.Errorf("redirect hits = %d, want at most 10", hits) + } +} + +// Prefix checks alone are insufficient: URL dot segments can resolve an API +// path to a different endpoint before the request is sent. +func TestAPIRequestRejectsPathsEscapingAPI(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request outside API prefix: %s", r.URL) + })) + defer server.Close() + + c := New(server.URL, WithTokens("sdm_test", "")) + for _, path := range []string{ + "/api/../settings", + "/api/%2e%2e/settings", + "/api/%2E%2E%2Fsettings", + "/api/%252e%252e/settings", + "/api/%252E%252E%252Fsettings", + } { + t.Run(path, func(t *testing.T) { + _, err := c.APIRequest(context.Background(), http.MethodGet, path, nil, nil) + if err == nil { + t.Fatal("APIRequest error = nil, want API-prefix escape rejection") + } + }) + } +} + +// The contract requires PATH to begin with /api/, not merely to normalize to +// the /api route itself. +func TestAPIRequestRejectsBareAPIPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request to bare API path: %s", r.URL) + })) + defer server.Close() + + c := New(server.URL, WithTokens("sdm_test", "")) + if _, err := c.APIRequest(context.Background(), http.MethodGet, "/api", nil, nil); err == nil { + t.Fatal("APIRequest error = nil, want bare /api rejection") + } +} + +// Rejecting every redirect would break normal same-server routing, while +// allowing a different origin could forward an API token to an attacker. +func TestAPIRequestAllowsSameOriginRedirect(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.URL.Path == "/api/v1/redirect" { + http.Redirect(w, r, "/api/v1/final", http.StatusFound) + return + } + if got, want := r.URL.Path, "/api/v1/final"; got != want { + t.Errorf("path = %q, want %q", got, want) + } + if got, want := r.Header.Get("Authorization"), "Bearer sdm_test"; got != want { + t.Errorf("redirect authorization = %q, want %q", got, want) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + c := New(server.URL, WithTokens("sdm_test", "")) + response, err := c.APIRequest(context.Background(), http.MethodGet, "/api/v1/redirect", nil, nil) + if err != nil { + t.Fatalf("APIRequest: %v", err) + } + if response.StatusCode != http.StatusOK { + t.Errorf("status = %d, want %d", response.StatusCode, http.StatusOK) + } + if requests != 2 { + t.Errorf("requests = %d, want 2", requests) + } +} + +// Accepting an absolute or protocol-relative path would let a caller choose +// the credential destination instead of the configured Stackdome server. +func TestAPIRequestRejectsAbsoluteAndProtocolRelativePaths(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request to configured server: %s", r.URL) + })) + defer server.Close() + + c := New(server.URL, WithTokens("sdm_test", "")) + for _, path := range []string{"https://example.test/api/v1/users/current", "//example.test/api/v1/users/current"} { + t.Run(path, func(t *testing.T) { + _, err := c.APIRequest(context.Background(), http.MethodGet, path, nil, nil) + if err == nil { + t.Fatal("APIRequest error = nil, want unsafe target rejection") + } + }) + } +} + +// A cross-origin redirect must fail before the redirected server sees the +// request, especially its Authorization header. +func TestAPIRequestRejectsCrossOriginRedirectBeforeSendingCredentials(t *testing.T) { + var targetRequests int + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + targetRequests++ + if got := r.Header.Get("Authorization"); got != "" { + t.Errorf("Authorization = %q, want no credential", got) + } + })) + defer target.Close() + + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+"/api/v1/stolen", http.StatusFound) + })) + defer origin.Close() + + c := New(origin.URL, WithTokens("sdm_test", "")) + _, err := c.APIRequest(context.Background(), http.MethodGet, "/api/v1/redirect", nil, nil) + if err == nil { + t.Fatal("APIRequest error = nil, want cross-origin redirect rejection") + } + if !strings.Contains(err.Error(), "origin") { + t.Errorf("error = %q, want origin rejection", err) + } + if targetRequests != 0 { + t.Errorf("cross-origin target requests = %d, want 0", targetRequests) + } +} diff --git a/internal/client/auth.go b/internal/client/auth.go index 0330339..c5793c5 100644 --- a/internal/client/auth.go +++ b/internal/client/auth.go @@ -3,8 +3,8 @@ package client import ( "context" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" serverapi "github.com/Stackdome/stackdome/pkg/api/openapi" - clierrors "github.com/stackdome/cli/internal/errors" ) type LoginResult struct { diff --git a/internal/client/client.go b/internal/client/client.go index d70f47f..75a2af2 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -17,8 +17,8 @@ import ( "sync" "time" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" serverapi "github.com/Stackdome/stackdome/pkg/api/openapi" - clierrors "github.com/stackdome/cli/internal/errors" ) const defaultTimeout = 30 * time.Second @@ -93,6 +93,8 @@ func New(baseURL string, opts ...Option) *Client { base = http.DefaultTransport } cfg.HTTPClient.Transport = &refreshTransport{base: base, client: c} + configuredOrigin, _ := url.Parse(baseURL) + cfg.HTTPClient.CheckRedirect = sameOriginRedirectPolicy(configuredOrigin) c.apiClient = serverapi.NewAPIClient(cfg) c.applyAuth() @@ -102,6 +104,8 @@ func New(baseURL string, opts ...Option) *Client { // noRetryKey marks the refresh call itself, so a 401 on it cannot recurse. type noRetryKey struct{} +const replacementTokenURLHeader = "X-Stackdome-CLI-Replacement-Token-URL" + // refreshTransport turns a 401 into one refresh + one retry. The server rotates // the refresh token on every use, so the new pair is persisted immediately. type refreshTransport struct { @@ -115,7 +119,16 @@ func (t *refreshTransport) RoundTrip(req *http.Request) (*http.Response, error) if err != nil || !shouldRefresh(resp) { return resp, err } - if req.Context().Value(noRetryKey{}) != nil || !t.client.canRefresh() { + if req.Context().Value(noRetryKey{}) != nil { + return resp, nil + } + if !t.client.canRefresh() { + if t.client.accessToken != "" { + if resp.Header == nil { + resp.Header = make(http.Header) + } + resp.Header.Set(replacementTokenURLHeader, t.client.replacementTokenURL()) + } return resp, nil } @@ -202,6 +215,10 @@ func (c *Client) canRefresh() bool { return c.refreshToken != "" && !strings.HasPrefix(c.accessToken, "sdm_") } +func (c *Client) replacementTokenURL() string { + return strings.TrimRight(c.baseURL, "/") + "/settings/api-tokens" +} + func (c *Client) API() *serverapi.DefaultApiService { return c.apiClient.DefaultApi } @@ -259,7 +276,22 @@ var errPersistTokens = errors.New("could not save refreshed credentials") func WrapError(httpResp *http.Response, err error, message string) error { if httpResp != nil { + if replacementURL := httpResp.Header.Get(replacementTokenURLHeader); replacementURL != "" { + return clierrors.AuthError(fmt.Sprintf( + "API token was rejected. Create a replacement token at %s, then run `stackdome login --token `.", + replacementURL, + )) + } reason := extractAPIReason(err) + if httpResp.StatusCode == http.StatusForbidden { + if reason == "" { + reason = err.Error() + } + return clierrors.New("Permission denied."). + WithCode("FORBIDDEN"). + WithExitCode(clierrors.ExitAuth). + WithDetail(reason) + } if reason != "" { return clierrors.FromHTTP(httpResp.StatusCode, reason) } @@ -271,6 +303,10 @@ func WrapError(httpResp *http.Response, err error, message string) error { return clierrors.Wrapf(err, "%s", message) } +func wrapHTTPResponseError(httpResp *http.Response, message string) error { + return WrapError(httpResp, errors.New(message), message) +} + type bodyer interface { Body() []byte } diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 6810c4b..25e854b 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -12,7 +12,7 @@ import ( "strings" "testing" - clierrors "github.com/stackdome/cli/internal/errors" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" ) // refreshServer serves /api/v1/config, rejecting anything but wantToken with a @@ -138,6 +138,106 @@ func TestNoRefreshTokenSurfaces401(t *testing.T) { } } +// An unrefreshable credential cannot recover from a genuine access-token +// rejection. The client-facing API call must tell the user where to create a +// replacement token without exposing the rejected credential. +func TestUnrefreshableTokenRejectionDirectsToTokenSettings(t *testing.T) { + for _, tc := range []struct { + name string + accessToken string + refreshToken string + status int + body string + }{ + { + name: "sdm token rejected with 401", + accessToken: "sdm_rejected_token", + refreshToken: "refresh-old", + status: http.StatusUnauthorized, + body: `{"reason":"token expired"}`, + }, + { + name: "token without refresh pair rejected with token expired 403", + accessToken: "opaque-rejected-token", + status: http.StatusForbidden, + body: `{"reason":"token expired"}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + srv := &refreshServer{ + t: t, + wantToken: "accepted-token", + rejectStatus: tc.status, + rejectBody: tc.body, + } + ts := httptest.NewServer(srv.handler()) + defer ts.Close() + + c := New(ts.URL, WithTokens(tc.accessToken, tc.refreshToken)) + _, err := c.GetCurrentUser(context.Background()) + if err == nil { + t.Fatal("expected auth error") + } + if srv.authHits != 0 { + t.Errorf("refresh endpoint hit %d times, want 0", srv.authHits) + } + + var cliErr *clierrors.CLIError + if !errors.As(err, &cliErr) || cliErr.ExitCode != clierrors.ExitAuth { + t.Fatalf("error = %#v, want CLI auth error", err) + } + wantURL := ts.URL + "/settings/api-tokens" + if !strings.Contains(cliErr.Message, wantURL) { + t.Errorf("message = %q, want replacement-token URL %q", cliErr.Message, wantURL) + } + if !strings.Contains(cliErr.Message, "stackdome login --token") { + t.Errorf("message = %q, want replacement-token login guidance", cliErr.Message) + } + if strings.Contains(err.Error(), tc.accessToken) { + t.Errorf("error leaked rejected token: %q", err) + } + }) + } +} + +// A 403 that does not match the access-token rejection contract is a real +// permission denial. Its server reason must remain visible without describing +// the token as expired. +func TestPermissionDenied403RetainsReasonWithoutExpiryGuidance(t *testing.T) { + const denied = `{"code":403,"id":"forbidden","kind":"auth","reason":"insufficient permissions"}` + srv := &refreshServer{ + t: t, + wantToken: "accepted-token", + rejectStatus: http.StatusForbidden, + rejectBody: denied, + } + ts := httptest.NewServer(srv.handler()) + defer ts.Close() + + c := New(ts.URL, WithTokens("sdm_permission_denied", "")) + _, err := c.GetCurrentUser(context.Background()) + if err == nil { + t.Fatal("expected permission error") + } + if srv.authHits != 0 { + t.Errorf("refresh endpoint hit %d times, want 0", srv.authHits) + } + + var cliErr *clierrors.CLIError + if !errors.As(err, &cliErr) { + t.Fatalf("error = %#v, want CLI error", err) + } + if cliErr.Message != "Permission denied." { + t.Errorf("message = %q, want permission denial", cliErr.Message) + } + if cliErr.Detail != "insufficient permissions" { + t.Errorf("detail = %q, want original server reason", cliErr.Detail) + } + if strings.Contains(strings.ToLower(err.Error()), "expired") { + t.Errorf("error = %q, must not describe a permission denial as expired", err) + } +} + // A failure to write the rotated pair to disk must not fail the command: the // refresh succeeded, so retry with the live token and warn on stderr. func TestPersistFailureStillRetries(t *testing.T) { diff --git a/internal/client/logs.go b/internal/client/logs.go index e9ac60d..c222579 100644 --- a/internal/client/logs.go +++ b/internal/client/logs.go @@ -7,7 +7,7 @@ import ( "net/http" "strconv" - clierrors "github.com/stackdome/cli/internal/errors" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" ) type LogOptions struct { @@ -48,10 +48,7 @@ func (c *Client) openLogStream(ctx context.Context, path string, opts LogOptions // `-f` follows a log stream indefinitely; reuse the configured transport (so // token refresh still applies) but not its 30s whole-request timeout. The // context governs the stream's lifetime. - httpClient := &http.Client{} - if c.cfg.HTTPClient != nil { - httpClient.Transport = c.cfg.HTTPClient.Transport - } + httpClient := c.streamHTTPClient() resp, err := httpClient.Do(req) if err != nil { @@ -59,9 +56,22 @@ func (c *Client) openLogStream(ctx context.Context, path string, opts LogOptions } if resp.StatusCode != http.StatusOK { + streamErr := wrapHTTPResponseError(resp, "Log streaming failed") resp.Body.Close() - return nil, clierrors.FromHTTP(resp.StatusCode, "Log streaming failed") + return nil, streamErr } return resp.Body, nil } + +// streamHTTPClient keeps a stream's lifetime under its context rather than the +// default whole-request timeout, while retaining the configured auth-aware +// transport and redirect boundary. +func (c *Client) streamHTTPClient() *http.Client { + httpClient := &http.Client{} + if c.cfg.HTTPClient != nil { + httpClient.Transport = c.cfg.HTTPClient.Transport + httpClient.CheckRedirect = c.cfg.HTTPClient.CheckRedirect + } + return httpClient +} diff --git a/internal/client/releases.go b/internal/client/releases.go index 173c9ec..1fa6961 100644 --- a/internal/client/releases.go +++ b/internal/client/releases.go @@ -9,8 +9,8 @@ import ( "net/http" "time" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" openapi "github.com/Stackdome/stackdome/pkg/api/openapi" - clierrors "github.com/stackdome/cli/internal/errors" ) // reconnectBackoff is the pause before retrying a *failed* reconnect. A clean @@ -42,6 +42,19 @@ func (c *Client) CreateRelease(ctx context.Context, stackID string) (*openapi.St return resp, nil } +// RollbackRelease creates a new release using the document recorded by a +// previous release. The API intentionally models rollback as release creation +// with from_release_id rather than a separate endpoint. +func (c *Client) RollbackRelease(ctx context.Context, stackID, fromReleaseID string) (*openapi.StackRelease, error) { + resp, httpResp, err := c.apiClient.ReleasesApi. + CreateRelease(ctx, c.orgID, c.projectName, stackID). + CreateReleaseRequest(openapi.CreateReleaseRequest{FromReleaseId: openapi.PtrString(fromReleaseID)}).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to roll back release") + } + return resp, nil +} + func (c *Client) GetRelease(ctx context.Context, stackID, releaseID string) (*openapi.StackReleaseDetail, error) { resp, httpResp, err := c.apiClient.ReleasesApi. GetRelease(ctx, c.orgID, c.projectName, stackID, releaseID).Execute() @@ -170,18 +183,16 @@ func (c *Client) openReleaseEventStream(ctx context.Context, stackID, releaseID // A release can be quiet for minutes; reuse the configured transport (so // token refresh still applies) but not its 30s whole-request timeout. - httpClient := &http.Client{} - if c.cfg.HTTPClient != nil { - httpClient.Transport = c.cfg.HTTPClient.Transport - } + httpClient := c.streamHTTPClient() resp, err := httpClient.Do(req) if err != nil { return nil, clierrors.Wrap(err, "Failed to connect to release event stream") } if resp.StatusCode != http.StatusOK { + streamErr := wrapHTTPResponseError(resp, "Release event streaming failed") resp.Body.Close() - return nil, clierrors.FromHTTP(resp.StatusCode, "Release event streaming failed") + return nil, streamErr } return resp.Body, nil } diff --git a/internal/client/releases_test.go b/internal/client/releases_test.go index 6a1a933..06ed092 100644 --- a/internal/client/releases_test.go +++ b/internal/client/releases_test.go @@ -2,6 +2,7 @@ package client import ( "context" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -10,6 +11,41 @@ import ( "time" ) +func TestRollbackReleasePostsSourceReleaseID(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases" { + t.Fatalf("path = %s", r.URL.Path) + } + + var request struct { + FromReleaseID string `json:"from_release_id"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("decode request: %v", err) + } + if request.FromReleaseID != "release-previous" { + t.Errorf("from_release_id = %q, want %q", request.FromReleaseID, "release-previous") + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"release-rollback","stack_id":"stack-1","sequence":8,"state":"Pending"}`)) + })) + defer ts.Close() + + c := New(ts.URL, WithTokens("access", ""), WithOrgAndProject("org-1", "proj-1")) + release, err := c.RollbackRelease(context.Background(), "stack-1", "release-previous") + if err != nil { + t.Fatalf("RollbackRelease: %v", err) + } + if release.GetId() != "release-rollback" { + t.Errorf("new release ID = %q, want %q", release.GetId(), "release-rollback") + } +} + // TestStreamReleaseEventsResumesAfterDrop: the server hands out events 1-3 then // drops the connection mid-release. The client must reconnect from where it // left off (?after_sequence=3) and deliver 4-5 — every event exactly once. diff --git a/internal/client/stream_auth_test.go b/internal/client/stream_auth_test.go new file mode 100644 index 0000000..fdb8a50 --- /dev/null +++ b/internal/client/stream_auth_test.go @@ -0,0 +1,250 @@ +package client + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestStreamingEndpointsPreserveRejectedAPITokenGuidance(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"reason":"token expired"}`)) + })) + defer ts.Close() + + const rejectedToken = "sdm_rejected_stream_token" + c := New(ts.URL, WithTokens(rejectedToken, ""), WithOrgAndProject("org-1", "default")) + tests := []struct { + name string + call func() error + }{ + {name: "runtime logs", call: func() error { + _, err := c.StreamLogs(context.Background(), "stack-1", "web", LogOptions{Tail: 10}) + return err + }}, + {name: "release events", call: func() error { + _, err := c.StreamReleaseEvents(context.Background(), "stack-1", "release-1", 0) + return err + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.call() + if err == nil { + t.Fatal("stream succeeded, want rejected-token error") + } + message := err.Error() + if !strings.Contains(message, ts.URL+"/settings/api-tokens") || !strings.Contains(message, "stackdome login --token") { + t.Fatalf("error = %q, want replacement-token guidance", message) + } + if strings.Contains(message, rejectedToken) { + t.Fatalf("error leaked rejected token: %q", message) + } + }) + } +} + +// A stream client intentionally omits the normal whole-request timeout, but it +// must retain the configured redirect policy: a redirect may otherwise replay +// the bearer token to a different origin. +func TestStreamingEndpointsRejectCrossOriginRedirectBeforeForeignRequest(t *testing.T) { + foreignRequests := 0 + foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + foreignRequests++ + t.Errorf("foreign server received %s with Authorization %q", r.URL, r.Header.Get("Authorization")) + })) + defer foreign.Close() + + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, foreign.URL+"/stolen", http.StatusTemporaryRedirect) + })) + defer origin.Close() + + c := New(origin.URL, WithTokens("stream-secret", ""), WithOrgAndProject("org-1", "proj-1")) + for _, tt := range []struct { + name string + call func() error + }{ + {name: "runtime logs", call: func() error { + _, err := c.StreamLogs(context.Background(), "stack-1", "web", LogOptions{Follow: true}) + return err + }}, + {name: "build logs", call: func() error { + _, err := c.StreamBuildLogs(context.Background(), "stack-1", "build-1", LogOptions{Follow: true}) + return err + }}, + {name: "release events", call: func() error { + _, err := c.StreamReleaseEvents(context.Background(), "stack-1", "release-1", 0) + return err + }}, + } { + t.Run(tt.name, func(t *testing.T) { + if err := tt.call(); err == nil { + t.Fatal("stream succeeded after a foreign redirect") + } + if foreignRequests != 0 { + t.Fatalf("foreign requests = %d, want 0", foreignRequests) + } + }) + } +} + +// Redirects used for routing within the configured service remain valid for +// every no-timeout stream endpoint, and keep the bearer token on that origin. +func TestStreamingEndpointsAllowSameOriginRedirect(t *testing.T) { + const token = "stream-secret" + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/redirected" { + http.Redirect(w, r, "/redirected", http.StatusTemporaryRedirect) + return + } + if got, want := r.Header.Get("Authorization"), "Bearer "+token; got != want { + t.Errorf("Authorization = %q, want %q", got, want) + } + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "event: end\ndata: {}\n\n") + })) + defer origin.Close() + + c := New(origin.URL, WithTokens(token, ""), WithOrgAndProject("org-1", "proj-1")) + for _, tt := range []struct { + name string + call func() error + }{ + {name: "runtime logs", call: func() error { + body, err := c.StreamLogs(context.Background(), "stack-1", "web", LogOptions{Follow: true}) + if err != nil { + return err + } + defer body.Close() + return ParseSSEStream(body, func(SSEEvent) error { return nil }) + }}, + {name: "build logs", call: func() error { + body, err := c.StreamBuildLogs(context.Background(), "stack-1", "build-1", LogOptions{Follow: true}) + if err != nil { + return err + } + defer body.Close() + return ParseSSEStream(body, func(SSEEvent) error { return nil }) + }}, + {name: "release events", call: func() error { + events, err := c.StreamReleaseEvents(context.Background(), "stack-1", "release-1", 0) + if err != nil { + return err + } + for range events { + } + return nil + }}, + } { + t.Run(tt.name, func(t *testing.T) { + if err := tt.call(); err != nil { + t.Fatalf("same-origin redirected stream: %v", err) + } + }) + } +} + +// Host suffix matching is not origin matching. This exercises a real redirect +// with a parent hostname and a subdomain target, while the test transport maps +// both symbolic hosts to local servers. +func TestStreamLogsRejectsParentToSubdomainRedirect(t *testing.T) { + foreignRequests := 0 + foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + foreignRequests++ + t.Errorf("subdomain received Authorization %q", r.Header.Get("Authorization")) + })) + defer foreign.Close() + + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "http://logs.api.stackdome.test/stolen", http.StatusTemporaryRedirect) + })) + defer origin.Close() + + dialer := &net.Dialer{} + transport := &http.Transport{DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + switch address { + case "api.stackdome.test:80": + return dialer.DialContext(ctx, network, origin.Listener.Addr().String()) + case "logs.api.stackdome.test:80": + return dialer.DialContext(ctx, network, foreign.Listener.Addr().String()) + default: + return nil, fmt.Errorf("unexpected dial address %q", address) + } + }} + c := New("http://api.stackdome.test", WithTokens("stream-secret", ""), WithOrgAndProject("org-1", "proj-1")) + c.cfg.HTTPClient.Transport = &refreshTransport{base: transport, client: c} + + if _, err := c.StreamLogs(context.Background(), "stack-1", "web", LogOptions{Follow: true}); err == nil { + t.Fatal("stream succeeded after parent-to-subdomain redirect") + } + if foreignRequests != 0 { + t.Fatalf("subdomain requests = %d, want 0", foreignRequests) + } +} + +// Retaining the transport is essential for TLS options supplied by the client +// configuration; only the client timeout is deliberately omitted for streams. +func TestStreamLogsRetainsConfiguredTLSTransport(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "event: end\ndata: {}\n\n") + })) + defer server.Close() + + c := New(server.URL, WithInsecure(true), WithTokens("stream-secret", ""), WithOrgAndProject("org-1", "proj-1")) + body, err := c.StreamLogs(context.Background(), "stack-1", "web", LogOptions{Follow: true}) + if err != nil { + t.Fatalf("StreamLogs with configured TLS transport: %v", err) + } + defer body.Close() + if err := ParseSSEStream(body, func(SSEEvent) error { return nil }); err != nil { + t.Fatalf("ParseSSEStream: %v", err) + } +} + +// Copying the redirect policy must not bypass the refresh-aware transport. +func TestStreamLogsRefreshesAfterUnauthorized(t *testing.T) { + streamRequests := 0 + refreshRequests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/auth/refresh": + refreshRequests++ + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"token":"access-new","refreshToken":"refresh-new"}`) + default: + streamRequests++ + if got, want := r.Header.Get("Authorization"), "Bearer access-new"; streamRequests == 2 && got != want { + t.Errorf("refreshed Authorization = %q, want %q", got, want) + } + if streamRequests == 1 { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "event: end\ndata: {}\n\n") + } + })) + defer server.Close() + + c := New(server.URL, WithTokens("access-old", "refresh-old"), WithOrgAndProject("org-1", "proj-1")) + body, err := c.StreamLogs(context.Background(), "stack-1", "web", LogOptions{Follow: true}) + if err != nil { + t.Fatalf("StreamLogs after refresh: %v", err) + } + defer body.Close() + if err := ParseSSEStream(body, func(SSEEvent) error { return nil }); err != nil { + t.Fatalf("ParseSSEStream: %v", err) + } + if streamRequests != 2 || refreshRequests != 1 { + t.Errorf("stream requests = %d, refresh requests = %d; want 2 and 1", streamRequests, refreshRequests) + } +} diff --git a/internal/client/volumes.go b/internal/client/volumes.go index 6805289..47d0e92 100644 --- a/internal/client/volumes.go +++ b/internal/client/volumes.go @@ -6,8 +6,8 @@ import ( "fmt" "net/http" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" openapi "github.com/Stackdome/stackdome/pkg/api/openapi" - clierrors "github.com/stackdome/cli/internal/errors" ) // ListVolumes lists the volumes of a stack. @@ -49,7 +49,7 @@ func (c *Client) ListVolumes(ctx context.Context, stackID string) ([]openapi.Vol return list.GetItems(), nil } -func (c *Client) CreateVolume(ctx context.Context, name, size, accessMode string) (*openapi.Volume, error) { +func (c *Client) CreateVolume(ctx context.Context, stackID, name, size, accessMode string) (*openapi.Volume, error) { volume := openapi.Volume{ Name: name, Spec: openapi.VolumeSpec{ @@ -58,7 +58,7 @@ func (c *Client) CreateVolume(ctx context.Context, name, size, accessMode string }, } resp, httpResp, err := c.apiClient.DefaultApi. - ApiV1OrganizationsOrgIdProjectsProjectNameVolumesPost(ctx, c.orgID, c.projectName). + ApiV1OrganizationsOrgIdProjectsProjectNameStacksIdVolumesPost(ctx, c.orgID, c.projectName, stackID). Volume(volume).Execute() if err != nil { return nil, WrapError(httpResp, err, "Failed to create volume") diff --git a/internal/cmdutil/context.go b/internal/cmdutil/context.go index 50ddc12..12dd94f 100644 --- a/internal/cmdutil/context.go +++ b/internal/cmdutil/context.go @@ -4,9 +4,9 @@ import ( "log/slog" "os" - "github.com/stackdome/cli/internal/client" - "github.com/stackdome/cli/internal/config" - "github.com/stackdome/cli/internal/output" + "github.com/Stackdome/stackdome-cli/internal/client" + "github.com/Stackdome/stackdome-cli/internal/config" + "github.com/Stackdome/stackdome-cli/internal/output" ) type CommandContext struct { diff --git a/internal/cmdutil/middleware.go b/internal/cmdutil/middleware.go index a35c27a..8c73ee7 100644 --- a/internal/cmdutil/middleware.go +++ b/internal/cmdutil/middleware.go @@ -1,8 +1,8 @@ package cmdutil import ( + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" "github.com/spf13/cobra" - clierrors "github.com/stackdome/cli/internal/errors" ) type contextKey struct{} @@ -29,19 +29,19 @@ func RequireAuth(fn RunEWithContext) RunEWithContext { if err := ctx.Config.RequireAuth(); err != nil { return err } - if err := resolveScope(ctx, cmd); err != nil { + if err := ResolveScope(ctx, cmd); err != nil { return err } return fn(ctx, cmd, args) } } -// resolveScope fills in the org/project the client scopes its calls to. With -// STACKDOME_TOKEN and no config file there is nothing on disk to read them -// from, so resolve them from the API once, in memory only — unless -// STACKDOME_ORG and STACKDOME_PROJECT already supplied both, in which case no -// discovery call is made at all (a scoped API token may not be allowed one). -func resolveScope(ctx *CommandContext, cmd *cobra.Command) error { +// ResolveScope fills in a missing organization/project for an authenticated +// context. With STACKDOME_TOKEN and no config file, it discovers them in +// memory unless STACKDOME_ORG and STACKDOME_PROJECT supplied both. Commands +// that must reject ephemeral contexts before discovery call this directly +// after that validation. +func ResolveScope(ctx *CommandContext, cmd *cobra.Command) error { if ctx.Config.OrganizationID != "" && ctx.Config.ProjectName != "" { return nil } diff --git a/internal/cmdutil/middleware_test.go b/internal/cmdutil/middleware_test.go index 9f4aeae..9ca24a0 100644 --- a/internal/cmdutil/middleware_test.go +++ b/internal/cmdutil/middleware_test.go @@ -4,9 +4,9 @@ import ( "strings" "testing" + "github.com/Stackdome/stackdome-cli/internal/config" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" "github.com/spf13/cobra" - "github.com/stackdome/cli/internal/config" - clierrors "github.com/stackdome/cli/internal/errors" ) // With org and project supplied (e.g. via STACKDOME_ORG / STACKDOME_PROJECT) @@ -24,8 +24,8 @@ func TestResolveScopeSkipsDiscoveryWhenScopeKnown(t *testing.T) { t.Fatal(err) } ctx := &CommandContext{Config: cfg} - if err := resolveScope(ctx, &cobra.Command{}); err != nil { - t.Fatalf("resolveScope: %v", err) + if err := ResolveScope(ctx, &cobra.Command{}); err != nil { + t.Fatalf("ResolveScope: %v", err) } } diff --git a/internal/cmdutil/prompt.go b/internal/cmdutil/prompt.go index cbaad90..c448b3c 100644 --- a/internal/cmdutil/prompt.go +++ b/internal/cmdutil/prompt.go @@ -7,8 +7,8 @@ import ( "os" "strings" - clierrors "github.com/stackdome/cli/internal/errors" - "github.com/stackdome/cli/internal/output" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + "github.com/Stackdome/stackdome-cli/internal/output" "golang.org/x/term" ) diff --git a/internal/cmdutil/prompt_test.go b/internal/cmdutil/prompt_test.go index a6b1065..9e17171 100644 --- a/internal/cmdutil/prompt_test.go +++ b/internal/cmdutil/prompt_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - clierrors "github.com/stackdome/cli/internal/errors" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" ) func TestConfirmNonTTYWithoutYesErrors(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index d5b327a..4949c62 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,7 +6,7 @@ import ( "os" "path/filepath" - clierrors "github.com/stackdome/cli/internal/errors" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" ) const ( @@ -36,6 +36,7 @@ type Config struct { fileRefreshToken string `json:"-"` fileOrgID string `json:"-"` fileProjectName string `json:"-"` + fileCurrentStack string `json:"-"` envServerURL string `json:"-"` envAccessToken string `json:"-"` envOrgID string `json:"-"` @@ -50,6 +51,20 @@ func (c *Config) TokenFromEnv() bool { return c.envAccessToken != "" && c.AccessToken == c.envAccessToken } +// ContextFromEnv reports whether environment values currently control the +// active server or credential. A persisted context switch cannot take effect +// while either override remains active in subsequent CLI processes. +func (c *Config) ContextFromEnv() bool { + return c.urlFromEnv() || c.TokenFromEnv() +} + +// StackContextFromEnv reports whether an environment value currently controls +// any part of the server/scope used to resolve a stack. A stack ID selected in +// that ephemeral context must not be persisted into the file-backed context. +func (c *Config) StackContextFromEnv() bool { + return c.ContextFromEnv() || c.orgFromEnv() || c.projectFromEnv() +} + // AdoptEnvValues drops the env latches so Save writes the current values to // disk verbatim. login/signup call it: an explicit login must persist a full // config even when the values happen to equal STACKDOME_URL / STACKDOME_TOKEN. @@ -62,6 +77,14 @@ func (c *Config) urlFromEnv() bool { return c.envServerURL != "" && c.ServerURL == c.envServerURL } +func (c *Config) orgFromEnv() bool { + return c.envOrgID != "" && c.OrganizationID == c.envOrgID +} + +func (c *Config) projectFromEnv() bool { + return c.envProjectName != "" && c.ProjectName == c.envProjectName +} + func DefaultPath() (string, error) { if p := os.Getenv("STACKDOME_CONFIG"); p != "" { return p, nil @@ -93,6 +116,7 @@ func Load() (*Config, error) { func (c *Config) applyEnv() { c.fileServerURL, c.fileAccessToken, c.fileRefreshToken = c.ServerURL, c.AccessToken, c.RefreshToken c.fileOrgID, c.fileProjectName = c.OrganizationID, c.ProjectName + c.fileCurrentStack = c.CurrentStack if v := os.Getenv("STACKDOME_URL"); v != "" { c.ServerURL = v @@ -111,6 +135,9 @@ func (c *Config) applyEnv() { c.ProjectName = v c.envProjectName = v } + if c.StackContextFromEnv() { + c.CurrentStack = "" + } } func LoadFrom(path string) (*Config, error) { @@ -165,12 +192,15 @@ func (c *Config) Save() error { if c.TokenFromEnv() { out.AccessToken, out.RefreshToken = c.fileAccessToken, c.fileRefreshToken } - if c.envOrgID != "" && c.OrganizationID == c.envOrgID { + if c.orgFromEnv() { out.OrganizationID = c.fileOrgID } - if c.envProjectName != "" && c.ProjectName == c.envProjectName { + if c.projectFromEnv() { out.ProjectName = c.fileProjectName } + if c.StackContextFromEnv() { + out.CurrentStack = c.fileCurrentStack + } data, err := json.MarshalIndent(&out, "", " ") if err != nil { @@ -209,16 +239,19 @@ func (c *Config) RequireStack() (string, error) { return "", err } if c.CurrentStack == "" { - return "", clierrors.New("No stack selected. Run `stackdome deploy` or use `--stack `.") + 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 stack list`, then `stackdome stack use `; or pass `--stack `.") } return c.CurrentStack, nil } func (c *Config) SetCurrentStack(name string) error { c.CurrentStack = name - // An env-token session is stateless by design: don't create (or fail on) - // a config file just to remember the stack for a process that's exiting. - if c.TokenFromEnv() { + // Environment-selected servers/scopes are ephemeral: don't leak their + // stack IDs into the different context stored in the config file. + if c.StackContextFromEnv() { return nil } return c.Save() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cd76c54..846221c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -151,6 +151,82 @@ func TestSetCurrentStackWithEnvTokenDoesNotWriteFile(t *testing.T) { } } +func TestSetCurrentStackWithEnvironmentSelectionOverrideDoesNotWriteFile(t *testing.T) { + tests := []struct { + name string + key string + value string + }{ + {name: "server", key: "STACKDOME_URL", value: "https://env.example"}, + {name: "organization", key: "STACKDOME_ORG", value: "env-org"}, + {name: "project", key: "STACKDOME_PROJECT", value: "env-project"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := writeConfig(t, `{"server_url":"https://file.example","access_token":"file-token","organization_id":"file-org","project_name":"default","current_stack":"file-stack"}`) + t.Setenv("STACKDOME_CONFIG", path) + t.Setenv(tt.key, tt.value) + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if err := cfg.SetCurrentStack("env-stack"); err != nil { + t.Fatalf("SetCurrentStack: %v", err) + } + + persisted, err := LoadFrom(path) + if err != nil { + t.Fatal(err) + } + if persisted.CurrentStack != "file-stack" { + t.Fatalf("persisted CurrentStack = %q, want file-stack", persisted.CurrentStack) + } + }) + } +} + +func TestEnvironmentSelectionOverrideHidesAndPreservesFileStack(t *testing.T) { + tests := []struct { + name string + key string + value string + }{ + {name: "server", key: "STACKDOME_URL", value: "https://env.example"}, + {name: "token", key: "STACKDOME_TOKEN", value: "env-token"}, + {name: "organization", key: "STACKDOME_ORG", value: "env-org"}, + {name: "project", key: "STACKDOME_PROJECT", value: "env-project"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := writeConfig(t, `{"server_url":"https://file.example","access_token":"file-token","organization_id":"file-org","project_name":"default","current_stack":"file-stack"}`) + t.Setenv("STACKDOME_CONFIG", path) + t.Setenv(tt.key, tt.value) + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.CurrentStack != "" { + t.Fatalf("environment-selected context inherited file stack %q", cfg.CurrentStack) + } + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + + persisted, err := LoadFrom(path) + if err != nil { + t.Fatal(err) + } + if persisted.CurrentStack != "file-stack" { + t.Fatalf("persisted CurrentStack = %q, want file-stack", persisted.CurrentStack) + } + }) + } +} + func TestLoadFrom_AdoptsLegacyTeamName(t *testing.T) { cfg, err := LoadFrom(writeConfig(t, `{"server_url":"https://x","team_name":"acme"}`)) if err != nil { @@ -218,3 +294,74 @@ func TestOrgAndProjectFromEnvNotPersisted(t *testing.T) { t.Errorf("file scope was clobbered: %s", data) } } + +func TestRequireStackErrorExplainsHowToSelectExistingStack(t *testing.T) { + cfg := &Config{ServerURL: "https://stackdome.example", AccessToken: "sdm_test"} + _, err := cfg.RequireStack() + if err == nil { + t.Fatal("RequireStack error = nil, want missing-stack guidance") + } + message := err.Error() + for _, want := range []string{"stackdome stack list", "stackdome stack use ", "--stack "} { + if !strings.Contains(message, want) { + t.Errorf("RequireStack error = %q, want %q", message, want) + } + } +} + +func TestRequireStackWithEnvironmentTokenDoesNotRecommendPersistentSelection(t *testing.T) { + path := writeConfig(t, `{"server_url":"https://stackdome.example"}`) + t.Setenv("STACKDOME_CONFIG", path) + t.Setenv("STACKDOME_TOKEN", "sdm_ephemeral") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + _, err = cfg.RequireStack() + if err == nil { + t.Fatal("RequireStack error = nil, want missing-stack guidance") + } + message := err.Error() + if !strings.Contains(message, "--stack ") || !strings.Contains(message, "stackdome stack list") { + t.Fatalf("RequireStack error = %q, want list and --stack guidance", message) + } + if strings.Contains(message, "stack use") { + t.Fatalf("RequireStack error = %q, must not recommend a selection that an environment-token process cannot persist", message) + } +} + +func TestRequireStackWithEnvironmentSelectionOverrideDoesNotRecommendPersistentSelection(t *testing.T) { + tests := []struct { + name string + key string + value string + }{ + {name: "server", key: "STACKDOME_URL", value: "https://env.example"}, + {name: "organization", key: "STACKDOME_ORG", value: "env-org"}, + {name: "project", key: "STACKDOME_PROJECT", value: "env-project"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := writeConfig(t, `{"server_url":"https://file.example","access_token":"file-token","organization_id":"file-org","project_name":"default"}`) + t.Setenv("STACKDOME_CONFIG", path) + t.Setenv(tt.key, tt.value) + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + _, err = cfg.RequireStack() + if err == nil { + t.Fatal("RequireStack error = nil, want missing-stack guidance") + } + message := err.Error() + if !strings.Contains(message, "--stack ") || !strings.Contains(message, "stackdome stack list") { + t.Fatalf("RequireStack error = %q, want list and --stack guidance", message) + } + if strings.Contains(message, "stack use") { + t.Fatalf("RequireStack error = %q, must not recommend persistent selection under an environment override", message) + } + }) + } +} diff --git a/internal/output/formatter.go b/internal/output/formatter.go index aa1d32d..c4806b5 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -6,9 +6,9 @@ import ( "io" "os" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" "github.com/charmbracelet/lipgloss" lgTable "github.com/charmbracelet/lipgloss/table" - clierrors "github.com/stackdome/cli/internal/errors" "gopkg.in/yaml.v3" ) @@ -51,6 +51,12 @@ func (f *Formatter) PrintJSON(v any) error { return enc.Encode(v) } +// PrintJSONLine writes one compact JSON value followed by a newline, suitable +// for NDJSON streams where each event must be independently decodable. +func (f *Formatter) PrintJSONLine(v any) error { + return json.NewEncoder(f.Writer).Encode(v) +} + // PrintYAML routes through JSON first so the API types' `json:` tags decide the // key names — yaml.Marshal alone would emit lowercased Go field names. func (f *Formatter) PrintYAML(v any) error { @@ -84,6 +90,13 @@ func (f *Formatter) IsTable() bool { return f.Format == FormatTable } +func ValidateStreamingFormat(format Format) error { + if format == FormatYAML { + return clierrors.ValidationError("YAML output is not supported for streaming commands; use -o json or table") + } + return nil +} + func (f *Formatter) NewTable(headers ...string) *Table { return &Table{ writer: f.Writer, diff --git a/internal/output/formatter_test.go b/internal/output/formatter_test.go index 2689161..e75245c 100644 --- a/internal/output/formatter_test.go +++ b/internal/output/formatter_test.go @@ -2,10 +2,37 @@ package output import ( "bytes" + "encoding/json" "strings" "testing" ) +// PrintJSONLine is the stream-safe counterpart to PrintJSON: every emitted +// event occupies exactly one compact, independently decodable line. +func TestPrintJSONLineWritesOneCompactDocument(t *testing.T) { + var buf bytes.Buffer + f := &Formatter{Format: FormatJSON, Writer: &buf} + + if err := f.PrintJSONLine(map[string]any{"event": "log", "data": map[string]any{"message": "hello"}}); err != nil { + t.Fatalf("PrintJSONLine: %v", err) + } + + got := buf.String() + if strings.Contains(got, "\n ") { + t.Fatalf("stream document is indented: %q", got) + } + if strings.Count(got, "\n") != 1 { + t.Fatalf("line count = %d, want 1: %q", strings.Count(got, "\n"), got) + } + var line map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(got)), &line); err != nil { + t.Fatalf("stream line is not JSON: %v", err) + } + if line["event"] != "log" { + t.Errorf("event = %#v, want log", line["event"]) + } +} + // The API types carry only `json:` tags, so YAML must go through JSON to keep // the wire names instead of emitting lowercased Go field names. func TestPrintYAMLUsesJSONTags(t *testing.T) { diff --git a/internal/output/status.go b/internal/output/status.go index 2ebb209..adf57dd 100644 --- a/internal/output/status.go +++ b/internal/output/status.go @@ -15,34 +15,111 @@ import ( // the stack's release (live), not on the Stack/StackResource entities, so live may // be nil when the stack has never been released. func RenderStackStatus(w io.Writer, stack *openapi.Stack, live *openapi.ReleaseLiveStatus, showConditions bool) { - rel := StackRelease(stack) - - state := "Unknown" - if rel != nil && rel.State != nil { - state = string(*rel.State) + splitRelease := hasDistinctLatestAndServing(stack) + fmt.Fprintf(w, "Stack: %s\n", Bold(stack.Name)) + renderReleaseStatus(w, stack) + if splitRelease { + fmt.Fprintf(w, "Serving health: %s\n\n", releaseHealth(live)) + } else { + fmt.Fprintf(w, "Health: %s\n\n", releaseHealth(live)) } - fmt.Fprintf(w, "Stack: %-20s State: %s\n\n", Bold(stack.Name), StateColor(state)) - + rel := StackRelease(stack) if rel != nil && rel.Message != nil && *rel.Message != "" { - fmt.Fprintf(w, " %s\n\n", *rel.Message) + if splitRelease { + fmt.Fprintf(w, "Latest message: %s\n\n", *rel.Message) + } else { + fmt.Fprintf(w, " %s\n\n", *rel.Message) + } } + if splitRelease { + fmt.Fprintln(w, "Serving resources:") + } renderResourceTable(w, stack.Spec.StackResources, live) renderFailures(w, stack.Spec.StackResources, live, showConditions) } -// StackRelease returns the release that describes what the stack is actually -// serving: the converged one, falling back to the latest only when nothing has -// converged yet. Must match client.GetStackLiveStatus, which picks the release -// the resource rows come from — otherwise the header and the rows describe -// different releases. +func hasDistinctLatestAndServing(stack *openapi.Stack) bool { + return stack.LatestRelease != nil && stack.ConvergedRelease != nil && + !releasesMatch(stack.LatestRelease, stack.ConvergedRelease) +} + +func renderReleaseStatus(w io.Writer, stack *openapi.Stack) { + latest, serving := stack.LatestRelease, stack.ConvergedRelease + switch { + case releasesMatch(latest, serving): + fmt.Fprintf(w, "%-8s %s\n", "Release:", formatReleaseSummary(latest)) + case latest != nil: + fmt.Fprintf(w, "%-8s %s\n", "Latest:", formatReleaseSummary(latest)) + if serving != nil { + fmt.Fprintf(w, "%-8s %s\n", "Serving:", formatReleaseSummary(serving)) + } else { + fmt.Fprintf(w, "%-8s none\n", "Serving:") + } + case serving != nil: + fmt.Fprintf(w, "%-8s %s\n", "Release:", formatReleaseSummary(serving)) + default: + fmt.Fprintf(w, "%-8s none\n", "Release:") + } +} + +func releasesMatch(latest, serving *openapi.ReleaseSummary) bool { + if latest == nil || serving == nil { + return false + } + if latest == serving { + return true + } + return latest.Id != nil && serving.Id != nil && *latest.Id != "" && *latest.Id == *serving.Id +} + +func formatReleaseSummary(release *openapi.ReleaseSummary) string { + if release == nil { + return "none" + } + parts := make([]string, 0, 4) + if release.Sequence != nil { + parts = append(parts, fmt.Sprintf("#%d", *release.Sequence)) + } + state := "Unknown" + if release.State != nil { + state = string(*release.State) + } + parts = append(parts, StateColor(state)) + if release.Id != nil && *release.Id != "" { + parts = append(parts, shortReleaseID(*release.Id)) + } + if release.CreatedAt != nil { + parts = append(parts, Dim(TimeAgo(*release.CreatedAt))) + } + return strings.Join(parts, " ") +} + +func shortReleaseID(id string) string { + if len(id) <= 8 { + return id + } + return id[:8] +} + +func releaseHealth(live *openapi.ReleaseLiveStatus) string { + if live == nil || live.Health == nil { + return "Unknown" + } + return StateColor(string(*live.Health)) +} + +// StackRelease returns the latest release, falling back to the converged +// release for stacks created before latest-release summaries were populated. +// List views use it so an active or failed rollout is not hidden by the older +// release that is still serving. func StackRelease(stack *openapi.Stack) *openapi.ReleaseSummary { - if stack.ConvergedRelease != nil { - return stack.ConvergedRelease + if stack.LatestRelease != nil { + return stack.LatestRelease } - return stack.LatestRelease + return stack.ConvergedRelease } // ResourceStatus returns the live status of a named stack resource, if any. diff --git a/internal/output/status_test.go b/internal/output/status_test.go index 075b1ca..a5789c2 100644 --- a/internal/output/status_test.go +++ b/internal/output/status_test.go @@ -1,11 +1,117 @@ package output import ( + "bytes" + "strings" "testing" + "time" openapi "github.com/Stackdome/stackdome/pkg/api/openapi" ) +func TestRenderStackStatusSeparatesLatestRolloutFromServingRelease(t *testing.T) { + SetNoColor(true) + t.Cleanup(func() { SetNoColor(false) }) + + now := time.Now() + latestID := "ad1642ba-1111-2222-3333-444444444444" + servingID := "56cd2d3e-1111-2222-3333-444444444444" + latestSequence, servingSequence := int32(2), int32(1) + latestState := openapi.RELEASE_STATE_IN_PROGRESS + servingState := openapi.RELEASE_STATE_RELEASED + health := openapi.RELEASE_HEALTH_PROGRESSING + message := "waiting for the new workload" + resourceState := "Ready" + resources := map[string]openapi.StackResourceStatus{ + "n8n": {State: &resourceState}, + } + stack := &openapi.Stack{ + Name: "n8n", + Spec: openapi.StackSpec{StackResources: []openapi.StackResource{{Name: "n8n"}}}, + LatestRelease: &openapi.ReleaseSummary{ + Id: &latestID, + Sequence: &latestSequence, + State: &latestState, + Message: &message, + CreatedAt: ptrTime(now.Add(-9*time.Minute - 10*time.Second)), + }, + ConvergedRelease: &openapi.ReleaseSummary{ + Id: &servingID, + Sequence: &servingSequence, + State: &servingState, + }, + } + + var got bytes.Buffer + RenderStackStatus(&got, stack, &openapi.ReleaseLiveStatus{Health: &health, Resources: &resources}, false) + out := got.String() + for _, want := range []string{ + "Stack: n8n", + "Latest:", "#2", "InProgress", "ad1642ba", "9m ago", + "Latest message:", message, + "Serving:", "#1", "Released", "56cd2d3e", + "Serving health:", "progressing", + "Serving resources:", "n8n", "Ready", + } { + if !strings.Contains(out, want) { + t.Errorf("status output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "State: Released") { + t.Errorf("status output still has misleading converged-release header:\n%s", out) + } +} + +func TestRenderStackStatusCollapsesConvergedLatestRelease(t *testing.T) { + SetNoColor(true) + t.Cleanup(func() { SetNoColor(false) }) + + releaseID := "d8c636b5-1111-2222-3333-444444444444" + sequence := int32(3) + state := openapi.RELEASE_STATE_RELEASED + health := openapi.RELEASE_HEALTH_OK + release := &openapi.ReleaseSummary{Id: &releaseID, Sequence: &sequence, State: &state} + stack := &openapi.Stack{ + Name: "n8n", + Spec: openapi.StackSpec{}, + LatestRelease: release, + ConvergedRelease: &openapi.ReleaseSummary{Id: &releaseID, Sequence: &sequence, State: &state}, + } + + var got bytes.Buffer + RenderStackStatus(&got, stack, &openapi.ReleaseLiveStatus{Health: &health}, false) + out := got.String() + for _, want := range []string{"Stack: n8n", "Release:", "#3", "Released", "d8c636b5", "Health:", "ok"} { + if !strings.Contains(out, want) { + t.Errorf("status output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "Latest:") || strings.Contains(out, "Serving:") { + t.Errorf("converged latest release was not collapsed:\n%s", out) + } + for _, verboseLabel := range []string{"Serving health:", "Serving resources:", "Latest message:"} { + if strings.Contains(out, verboseLabel) { + t.Errorf("converged latest release contains split-release label %q:\n%s", verboseLabel, out) + } + } +} + +func TestStackReleasePrefersLatestRelease(t *testing.T) { + latestID := "latest" + servingID := "serving" + stack := &openapi.Stack{ + LatestRelease: &openapi.ReleaseSummary{Id: &latestID}, + ConvergedRelease: &openapi.ReleaseSummary{Id: &servingID}, + } + + got := StackRelease(stack) + if got == nil || got.Id == nil || *got.Id != latestID { + t.Fatalf("StackRelease() = %#v, want latest release", got) + } +} + +func ptrTime(v time.Time) *time.Time { return &v } + func TestFormatPortsDefaultsProtocol(t *testing.T) { empty := "" tcp := "TCP" diff --git a/internal/stackfile/compose.go b/internal/stackfile/compose.go index f3d20d9..7130979 100644 --- a/internal/stackfile/compose.go +++ b/internal/stackfile/compose.go @@ -2,6 +2,7 @@ package stackfile import ( "fmt" + "net" "os" "path/filepath" "sort" @@ -14,18 +15,48 @@ import ( type composeFile struct { Services map[string]composeService `yaml:"services"` Volumes map[string]any `yaml:"volumes"` + Version any `yaml:"version"` + Name string `yaml:"name"` + Extra map[string]any `yaml:",inline"` } type composeService struct { - Image string `yaml:"image"` - Build any `yaml:"build"` - Command any `yaml:"command"` - Entrypoint any `yaml:"entrypoint"` - Ports []string `yaml:"ports"` - Environment any `yaml:"environment"` - EnvFile any `yaml:"env_file"` - Volumes []string `yaml:"volumes"` - DependsOn any `yaml:"depends_on"` + Image string `yaml:"image"` + Build any `yaml:"build"` + Command any `yaml:"command"` + Entrypoint any `yaml:"entrypoint"` + Ports []string `yaml:"ports"` + Environment any `yaml:"environment"` + EnvFile any `yaml:"env_file"` + Volumes []string `yaml:"volumes"` + DependsOn any `yaml:"depends_on"` + Extra map[string]any `yaml:",inline"` +} + +type ComposeWarnings struct { + EnvFiles map[string][]string + UnsupportedBindMounts map[string][]string + UnsupportedTopLevelKeys []string + UnsupportedServiceKeys map[string][]string + UnresolvedEnvironment map[string][]string + UnsupportedBuildOptions map[string][]string + UnsupportedDependsOnOptions map[string][]string + UnsupportedVolumeOptions map[string][]string + UnsupportedVolumeMountOptions map[string][]string + UnsupportedPorts map[string][]string + UnsupportedPortMappings map[string][]string + UnsupportedCommandForms map[string][]string +} + +type composeServiceWarnings struct { + unsupportedBindMounts []string + unresolvedEnvironment []string + unsupportedBuildOptions []string + unsupportedDependsOnOptions []string + unsupportedVolumeMountOptions []string + unsupportedPorts []string + unsupportedPortMappings []string + unsupportedCommandForms []string } func FindComposeFile(dir string) string { @@ -45,22 +76,21 @@ func FindComposeFile(dir string) string { } // FromCompose converts a docker-compose file into a stackfile. The second -// return value maps resource name -> the compose `env_file` it referenced: -// yaml.Marshal of a Stackfile cannot emit that key, so the caller reports them -// instead of dropping them silently. -func FromCompose(path, appName string) (*Stackfile, map[string]string, error) { +// return value records Compose features that Stackfiles cannot express, so the +// caller can warn rather than dropping them silently. +func FromCompose(path, appName string) (*Stackfile, ComposeWarnings, error) { data, err := os.ReadFile(path) if err != nil { - return nil, nil, fmt.Errorf("failed to read %s: %w", path, err) + return nil, ComposeWarnings{}, fmt.Errorf("failed to read %s: %w", path, err) } var compose composeFile if err := yaml.Unmarshal(data, &compose); err != nil { - return nil, nil, fmt.Errorf("failed to parse %s: %w", path, err) + return nil, ComposeWarnings{}, fmt.Errorf("failed to parse %s: %w", path, err) } if len(compose.Services) == 0 { - return nil, nil, fmt.Errorf("no services found in %s", path) + return nil, ComposeWarnings{}, fmt.Errorf("no services found in %s", path) } sf := &Stackfile{ @@ -68,236 +98,325 @@ func FromCompose(path, appName string) (*Stackfile, map[string]string, error) { Resources: make(map[string]Resource), Volumes: make(map[string]VolumeDef), } - envFiles := make(map[string]string) + warnings := ComposeWarnings{ + EnvFiles: make(map[string][]string), + UnsupportedBindMounts: make(map[string][]string), + UnsupportedTopLevelKeys: unsupportedComposeKeys(compose.Extra), + UnsupportedServiceKeys: make(map[string][]string), + UnresolvedEnvironment: make(map[string][]string), + UnsupportedBuildOptions: make(map[string][]string), + UnsupportedDependsOnOptions: make(map[string][]string), + UnsupportedVolumeOptions: make(map[string][]string), + UnsupportedVolumeMountOptions: make(map[string][]string), + UnsupportedPorts: make(map[string][]string), + UnsupportedPortMappings: make(map[string][]string), + UnsupportedCommandForms: make(map[string][]string), + } for name, svc := range compose.Services { - sf.Resources[name] = convertService(svc) - if ref := parseEnvFileRef(svc.EnvFile); ref != "" { - envFiles[name] = ref + res, serviceWarnings := convertService(svc) + sf.Resources[name] = res + if keys := unsupportedComposeKeys(svc.Extra); len(keys) > 0 { + warnings.UnsupportedServiceKeys[name] = keys + } + if len(serviceWarnings.unresolvedEnvironment) > 0 { + warnings.UnresolvedEnvironment[name] = serviceWarnings.unresolvedEnvironment + } + if refs := parseEnvFileRefs(svc.EnvFile); len(refs) > 0 { + warnings.EnvFiles[name] = refs + } + if len(serviceWarnings.unsupportedBindMounts) > 0 { + warnings.UnsupportedBindMounts[name] = serviceWarnings.unsupportedBindMounts + } + if len(serviceWarnings.unsupportedBuildOptions) > 0 { + warnings.UnsupportedBuildOptions[name] = serviceWarnings.unsupportedBuildOptions + } + if len(serviceWarnings.unsupportedDependsOnOptions) > 0 { + warnings.UnsupportedDependsOnOptions[name] = serviceWarnings.unsupportedDependsOnOptions + } + if len(serviceWarnings.unsupportedVolumeMountOptions) > 0 { + warnings.UnsupportedVolumeMountOptions[name] = serviceWarnings.unsupportedVolumeMountOptions + } + if len(serviceWarnings.unsupportedPorts) > 0 { + warnings.UnsupportedPorts[name] = serviceWarnings.unsupportedPorts + } + if len(serviceWarnings.unsupportedPortMappings) > 0 { + warnings.UnsupportedPortMappings[name] = serviceWarnings.unsupportedPortMappings + } + if len(serviceWarnings.unsupportedCommandForms) > 0 { + warnings.UnsupportedCommandForms[name] = serviceWarnings.unsupportedCommandForms } } - for volName := range compose.Volumes { + for volName, definition := range compose.Volumes { sf.Volumes[volName] = VolumeDef{Size: "1Gi"} + if options := unsupportedVolumeDefinitionOptions(definition); len(options) > 0 { + warnings.UnsupportedVolumeOptions[volName] = options + } } collectNamedVolumes(sf) - return sf, envFiles, nil + return sf, warnings, nil } -func convertService(svc composeService) Resource { +func convertService(svc composeService) (Resource, composeServiceWarnings) { var res Resource + var warnings composeServiceWarnings res.Image = svc.Image - res.Build = parseBuild(svc.Build) - res.Command, res.Args = parseCommandArgs(svc.Entrypoint, svc.Command) - res.Ports = parsePorts(svc.Ports) - res.Env = parseEnvironment(svc.Environment) - res.Volumes = parseVolumeMounts(svc.Volumes) - res.DependsOn = parseDependsOn(svc.DependsOn) - - return res + res.Build, warnings.unsupportedBuildOptions = parseBuild(svc.Build) + res.Command, res.Args, warnings.unsupportedCommandForms = parseCommandArgs(svc.Entrypoint, svc.Command) + res.Ports, warnings.unsupportedPorts, warnings.unsupportedPortMappings = parsePorts(svc.Ports) + res.Env, warnings.unresolvedEnvironment = parseEnvironment(svc.Environment) + res.Volumes, warnings.unsupportedBindMounts, warnings.unsupportedVolumeMountOptions = parseVolumeMounts(svc.Volumes) + res.DependsOn, warnings.unsupportedDependsOnOptions = parseDependsOn(svc.DependsOn) + + return res, warnings } -func parseCommandArgs(entrypoint, command any) (cmd []string, args []string) { - ep := parseStringOrList(entrypoint) - c := parseStringOrList(command) +func unsupportedComposeKeys(extra map[string]any) []string { + keys := make([]string, 0, len(extra)) + for key := range extra { + // Compose extension fields are inert definitions; any values merged from + // them have already materialized under ordinary service keys. + if strings.HasPrefix(key, "x-") { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} - if len(ep) > 0 { - // Both set: entrypoint → command, command → args - cmd = ep - args = c - } else { - // Only command set: maps to command (overrides the container's default) - cmd = c +func unsupportedVolumeDefinitionOptions(raw any) []string { + definition, ok := raw.(map[string]any) + if !ok { + if raw == nil { + return nil + } + return []string{""} } - return + return unsupportedComposeKeys(definition) } -func parseStringOrList(raw any) []string { +func parseCommandArgs(entrypoint, command any) (cmd []string, args []string, unsupported []string) { + var ambiguous bool + cmd, ambiguous = parseStringList(entrypoint) + if ambiguous { + unsupported = append(unsupported, "entrypoint") + } + args, ambiguous = parseStringList(command) + if ambiguous { + unsupported = append(unsupported, "command") + } + sort.Strings(unsupported) + return cmd, args, unsupported +} + +func parseStringList(raw any) ([]string, bool) { if raw == nil { - return nil + return nil, false } switch v := raw.(type) { case string: - fields := strings.Fields(v) - if len(fields) == 0 { - return nil - } - return fields + // Compose string forms have quoting and shell semantics that a + // Stackfile argv cannot preserve safely. Require an explicit list. + return nil, true case []any: - var out []string + if len(v) == 0 { + return nil, true + } + out := make([]string, 0, len(v)) for _, item := range v { - if s, ok := item.(string); ok { - out = append(out, s) + s, ok := item.(string) + if !ok { + return nil, true } + out = append(out, s) } - return out + return out, false } - return nil + return nil, true } -func parseEnvFileRef(raw any) string { - if raw == nil { - return "" - } +func parseEnvFileRefs(raw any) []string { switch v := raw.(type) { case string: - return v + if v != "" { + return []string{v} + } case []any: - if len(v) > 0 { - if s, ok := v[0].(string); ok { - return s + refs := make([]string, 0, len(v)) + for _, item := range v { + switch entry := item.(type) { + case string: + if entry != "" { + refs = append(refs, entry) + } + case map[string]any: + if path, ok := entry["path"].(string); ok && path != "" { + refs = append(refs, path) + } } } + return refs + case map[string]any: + if path, ok := v["path"].(string); ok && path != "" { + return []string{path} + } } - return "" + return nil } -func parseBuild(raw any) *BuildConfig { +func parseBuild(raw any) (*BuildConfig, []string) { if raw == nil { - return nil + return nil, nil } switch v := raw.(type) { case string: - return &BuildConfig{Context: v} + return &BuildConfig{Context: v}, nil case map[string]any: bc := &BuildConfig{} - if ctx, ok := v["context"].(string); ok { - bc.Context = ctx - } - if df, ok := v["dockerfile"].(string); ok { - bc.Dockerfile = df + var unsupported []string + for key, value := range v { + switch key { + case "context": + if context, ok := value.(string); ok { + bc.Context = context + } else { + unsupported = append(unsupported, key) + } + case "dockerfile": + if dockerfile, ok := value.(string); ok { + bc.Dockerfile = dockerfile + } else { + unsupported = append(unsupported, key) + } + default: + if !strings.HasPrefix(key, "x-") { + unsupported = append(unsupported, key) + } + } } - return bc + sort.Strings(unsupported) + return bc, unsupported } - return nil + return nil, []string{""} } -func parsePorts(ports []string) []PortDef { +func parsePorts(ports []string) ([]PortDef, []string, []string) { if len(ports) == 0 { - return nil + return nil, nil, nil } var defs []PortDef + var unsupported []string + var unsupportedMappings []string for _, p := range ports { - def := parsePort(p) + def, mappingWarning := parsePort(p) if def != nil { defs = append(defs, *def) + if mappingWarning { + unsupportedMappings = append(unsupportedMappings, p) + } + } else { + unsupported = append(unsupported, p) } } - return defs + sort.Strings(unsupported) + sort.Strings(unsupportedMappings) + return defs, unsupported, unsupportedMappings } -func parsePort(s string) *PortDef { +func parsePort(s string) (*PortDef, bool) { s = strings.TrimSpace(s) - protocol := "" + protocol := "TCP" if idx := strings.Index(s, "/"); idx != -1 { protocol = strings.ToUpper(s[idx+1:]) s = s[:idx] + if protocol != "TCP" { + return nil, false + } } - parts := strings.Split(s, ":") - var containerPort string - hostMapped := false - - switch len(parts) { - case 1: - containerPort = parts[0] - case 2: - containerPort = parts[1] - hostMapped = true - case 3: - containerPort = parts[2] - hostMapped = true - default: - return nil + hostIP, publishedPort, containerPort, hostMapped, ok := splitPortMapping(s) + if !ok { + return nil, false } - - portRange := strings.Split(containerPort, "-") - port, err := strconv.ParseInt(portRange[0], 10, 32) - if err != nil { - return nil + port, err := strconv.ParseInt(containerPort, 10, 32) + if err != nil || port <= 0 || port > 65535 { + return nil, false } - def := &PortDef{ - Port: int32(port), + published := port + if hostMapped { + published, err = strconv.ParseInt(publishedPort, 10, 32) + if err != nil || published <= 0 || published > 65535 { + return nil, false + } } - if protocol != "" && protocol != "TCP" { - def.Protocol = protocol + def := &PortDef{ + Port: int32(port), + Protocol: protocol, } def.Name = portName(int32(port), protocol) - if hostMapped && !isBackingServicePort(int32(port)) { + mappingWarning := false + if hostMapped { def.Public = true + if published != port { + mappingWarning = true + } + if hostIP != "" { + ip := net.ParseIP(hostIP) + if ip == nil { + return nil, false + } + if !ip.IsUnspecified() { + def.Public = false + mappingWarning = true + } + } } - return def + return def, mappingWarning } -func isBackingServicePort(port int32) bool { - switch port { - case 5432: // PostgreSQL - return true - case 3306: // MySQL / MariaDB - return true - case 6379: // Redis - return true - case 27017: // MongoDB - return true - case 9092: // Kafka - return true - case 4222: // NATS - return true - case 2181: // ZooKeeper - return true - case 9200: // Elasticsearch / OpenSearch - return true - case 5672: // RabbitMQ (AMQP) - return true - case 11211: // Memcached - return true - case 8123: // ClickHouse (HTTP) - return true - case 9000: // ClickHouse (native) - return true - case 6650: // Apache Pulsar - return true - case 7687: // Neo4j (Bolt) - return true - case 8529: // ArangoDB - return true - case 9042: // Cassandra (CQL) - return true - case 7000: // Cassandra (inter-node) - return true - case 6380: // KeyDB / Valkey - return true - case 26257: // CockroachDB - return true - case 28015: // RethinkDB - return true - case 8086: // InfluxDB - return true - case 1433: // SQL Server - return true - case 1521: // Oracle DB - return true - case 6363: // Milvus (vector DB) - return true - case 19530: // Milvus (gRPC) - return true - case 6333: // Qdrant (vector DB) - return true - case 8484: // Weaviate (vector DB) - return true - } - return false +func splitPortMapping(value string) (hostIP, published, container string, mapped, ok bool) { + lastSeparator := strings.LastIndex(value, ":") + if lastSeparator < 0 { + if value == "" || strings.Contains(value, "-") { + return "", "", "", false, false + } + return "", "", value, false, true + } + + container = value[lastSeparator+1:] + prefix := value[:lastSeparator] + secondSeparator := strings.LastIndex(prefix, ":") + if secondSeparator < 0 { + if prefix == "" || container == "" || strings.Contains(prefix, "-") || strings.Contains(container, "-") { + return "", "", "", false, false + } + return "", prefix, container, true, true + } + + hostIP = prefix[:secondSeparator] + published = prefix[secondSeparator+1:] + if strings.HasPrefix(hostIP, "[") && strings.HasSuffix(hostIP, "]") { + hostIP = strings.TrimSuffix(strings.TrimPrefix(hostIP, "["), "]") + } else if strings.Contains(hostIP, ":") { + return "", "", "", false, false + } + if hostIP == "" || published == "" || container == "" || strings.Contains(published, "-") || strings.Contains(container, "-") { + return "", "", "", false, false + } + return hostIP, published, container, true, true } func portName(port int32, _ string) string { @@ -323,16 +442,26 @@ func portName(port int32, _ string) string { } } -func parseEnvironment(raw any) map[string]string { +func parseEnvironment(raw any) (map[string]string, []string) { if raw == nil { - return nil + return nil, nil } env := make(map[string]string) + var unresolved []string switch v := raw.(type) { case map[string]any: for key, val := range v { + if val == nil { + unresolved = append(unresolved, key) + continue + } + switch val.(type) { + case map[string]any, []any: + unresolved = append(unresolved, key) + continue + } env[key] = fmt.Sprintf("%v", val) } case []any: @@ -341,32 +470,43 @@ func parseEnvironment(raw any) map[string]string { if !ok { continue } - key, val, _ := strings.Cut(s, "=") + key, val, found := strings.Cut(s, "=") + if !found { + if key != "" { + unresolved = append(unresolved, key) + } + continue + } env[key] = val } } + sort.Strings(unresolved) if len(env) == 0 { - return nil + env = nil } - return env + return env, unresolved } -func parseVolumeMounts(volumes []string) []VolumeMountDef { +func parseVolumeMounts(volumes []string) ([]VolumeMountDef, []string, []string) { if len(volumes) == 0 { - return nil + return nil, nil, nil } - var mounts []VolumeMountDef + var ( + mounts []VolumeMountDef + unsupportedBinds []string + unsupported []string + ) for _, v := range volumes { - parts := strings.SplitN(v, ":", 3) - if len(parts) < 2 { + source, target, mode, ok := splitVolumeMount(v) + if !ok { + unsupported = append(unsupported, v) continue } - source := parts[0] - target := parts[1] - if strings.HasPrefix(source, "/") || strings.HasPrefix(source, ".") { + if isBindMountSource(source) { + unsupportedBinds = append(unsupportedBinds, source) continue } @@ -374,17 +514,59 @@ func parseVolumeMounts(volumes []string) []VolumeMountDef { Name: source, Path: target, }) + if mode != "" { + unsupported = append(unsupported, v) + } } if len(mounts) == 0 { - return nil + mounts = nil } - return mounts + sort.Strings(unsupportedBinds) + sort.Strings(unsupported) + return mounts, unsupportedBinds, unsupported } -func parseDependsOn(raw any) []string { +func splitVolumeMount(value string) (string, string, string, bool) { + separator := strings.Index(value, ":") + if isWindowsDrivePath(value) { + rest := value[2:] + next := strings.Index(rest, ":") + if next < 0 { + return "", "", "", false + } + separator = next + 2 + } + if separator <= 0 || separator == len(value)-1 { + return "", "", "", false + } + target := value[separator+1:] + mode := "" + if modeSeparator := strings.Index(target, ":"); modeSeparator >= 0 { + mode = target[modeSeparator+1:] + target = target[:modeSeparator] + } + if target == "" { + return "", "", "", false + } + return value[:separator], target, mode, true +} + +func isWindowsDrivePath(value string) bool { + if len(value) < 3 || value[1] != ':' || (value[2] != '\\' && value[2] != '/') { + return false + } + drive := value[0] + return drive >= 'A' && drive <= 'Z' || drive >= 'a' && drive <= 'z' +} + +func isBindMountSource(source string) bool { + return strings.HasPrefix(source, "/") || strings.HasPrefix(source, ".") || isWindowsDrivePath(source) +} + +func parseDependsOn(raw any) ([]string, []string) { if raw == nil { - return nil + return nil, nil } switch v := raw.(type) { @@ -395,16 +577,29 @@ func parseDependsOn(raw any) []string { deps = append(deps, s) } } - return deps + return deps, nil case map[string]any: var deps []string - for name := range v { + var unsupported []string + for name, value := range v { deps = append(deps, name) + switch options := value.(type) { + case nil: + case map[string]any: + for key := range options { + if !strings.HasPrefix(key, "x-") { + unsupported = append(unsupported, name+"."+key) + } + } + default: + unsupported = append(unsupported, name) + } } sort.Strings(deps) - return deps + sort.Strings(unsupported) + return deps, unsupported } - return nil + return nil, []string{""} } func collectNamedVolumes(sf *Stackfile) { diff --git a/internal/stackfile/compose_test.go b/internal/stackfile/compose_test.go new file mode 100644 index 0000000..9d8086a --- /dev/null +++ b/internal/stackfile/compose_test.go @@ -0,0 +1,306 @@ +package stackfile_test + +import ( + "reflect" + "strings" + "testing" + + "github.com/Stackdome/stackdome-cli/internal/stackfile" +) + +func TestFromComposeReportsUnsupportedKeysAndUnresolvedEnvironmentDeterministically(t *testing.T) { + compose := write(t, "compose.yaml", `version: "3.9" +services: + worker: + image: busybox:latest + environment: + - SET=worker + - LIST_MISSING + web: + image: nginx:alpine + restart: always + healthcheck: + test: [CMD, curl, -f, http://localhost] + networks: [frontend] + secrets: [api-key] + configs: [web-config] + deploy: + replicas: 2 + profiles: [production] + mystery: true + environment: + SET: production + EMPTY: "" + NUMBER: 42 + MAP_MISSING: +networks: + frontend: {} +secrets: + api-key: + external: true +configs: + web-config: + file: ./web.conf +mystery: true +`) + + sf, warnings, err := stackfile.FromCompose(compose, "demo") + if err != nil { + t.Fatalf("FromCompose: %v", err) + } + + if want := []string{"configs", "mystery", "networks", "secrets"}; !reflect.DeepEqual(warnings.UnsupportedTopLevelKeys, want) { + t.Errorf("top-level warnings = %#v, want %#v", warnings.UnsupportedTopLevelKeys, want) + } + if want := []string{"configs", "deploy", "healthcheck", "mystery", "networks", "profiles", "restart", "secrets"}; !reflect.DeepEqual(warnings.UnsupportedServiceKeys["web"], want) { + t.Errorf("web unsupported keys = %#v, want %#v", warnings.UnsupportedServiceKeys["web"], want) + } + if want := map[string][]string{"web": {"MAP_MISSING"}, "worker": {"LIST_MISSING"}}; !reflect.DeepEqual(warnings.UnresolvedEnvironment, want) { + t.Errorf("unresolved environment = %#v, want %#v", warnings.UnresolvedEnvironment, want) + } + + webEnv := sf.Resources["web"].Env + if want := map[string]string{"EMPTY": "", "NUMBER": "42", "SET": "production"}; !reflect.DeepEqual(webEnv, want) { + t.Errorf("web env = %#v, want %#v", webEnv, want) + } + if got := sf.Resources["worker"].Env; !reflect.DeepEqual(got, map[string]string{"SET": "worker"}) { + t.Errorf("worker env = %#v, want only explicit value", got) + } + for name, res := range sf.Resources { + for key, value := range res.Env { + if value == "" || strings.Contains(value, "MAP_MISSING") || strings.Contains(value, "LIST_MISSING") { + t.Errorf("resource %s invented environment %s=%q", name, key, value) + } + } + } +} + +func TestFromComposeReportsNestedUnsupportedOptionsDeterministically(t *testing.T) { + compose := write(t, "compose.yaml", `services: + web: + image: nginx:alpine + build: + context: . + dockerfile: Containerfile + target: production + args: + MODE: release + cache_from: + - type=local + depends_on: + db: + condition: service_healthy + restart: true + required: false + environment: + PLAIN: supported + COMPLEX: + nested: value + LIST: [one, two] + volumes: + - data:/srv/data:ro + - cache:/srv/cache + ports: + - "8080:80" + - "8000-8002:9000-9002" + - not-a-port + db: + image: postgres:16 +volumes: + data: + driver: local + external: true + driver_opts: + type: none + labels: + tier: app + name: actual-data + cache: {} +`) + + sf, warnings, err := stackfile.FromCompose(compose, "demo") + if err != nil { + t.Fatalf("FromCompose: %v", err) + } + + assertStrings := func(label string, got, want []string) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Errorf("%s = %#v, want %#v", label, got, want) + } + } + assertStrings("build options", warnings.UnsupportedBuildOptions["web"], []string{"args", "cache_from", "target"}) + assertStrings("depends_on options", warnings.UnsupportedDependsOnOptions["web"], []string{"db.condition", "db.required", "db.restart"}) + assertStrings("top-level volume options", warnings.UnsupportedVolumeOptions["data"], []string{"driver", "driver_opts", "external", "labels", "name"}) + assertStrings("mount options", warnings.UnsupportedVolumeMountOptions["web"], []string{"data:/srv/data:ro"}) + assertStrings("ports", warnings.UnsupportedPorts["web"], []string{"8000-8002:9000-9002", "not-a-port"}) + assertStrings("complex environment", warnings.UnresolvedEnvironment["web"], []string{"COMPLEX", "LIST"}) + + web := sf.Resources["web"] + if web.Build == nil || web.Build.Context != "." || web.Build.Dockerfile != "Containerfile" { + t.Errorf("supported build fields = %#v, want context and dockerfile preserved", web.Build) + } + assertStrings("dependencies", web.DependsOn, []string{"db"}) + if want := map[string]string{"PLAIN": "supported"}; !reflect.DeepEqual(web.Env, want) { + t.Errorf("environment = %#v, want only supported scalar %#v", web.Env, want) + } + if want := []stackfile.VolumeMountDef{{Name: "data", Path: "/srv/data"}, {Name: "cache", Path: "/srv/cache"}}; !reflect.DeepEqual(web.Volumes, want) { + t.Errorf("volume mounts = %#v, want %#v", web.Volumes, want) + } + if want := []stackfile.PortDef{{Name: "http", Port: 80, Protocol: "TCP", Public: true}}; !reflect.DeepEqual(web.Ports, want) { + t.Errorf("ports = %#v, want only supported port %#v", web.Ports, want) + } + if _, ok := sf.Volumes["data"]; !ok { + t.Error("named volume data was not preserved") + } + if _, ok := sf.Volumes["cache"]; !ok { + t.Error("named volume cache was not preserved") + } + if _, ok := warnings.UnsupportedVolumeOptions["cache"]; ok { + t.Errorf("empty volume definition should not warn: %#v", warnings.UnsupportedVolumeOptions["cache"]) + } +} + +func TestFromComposePreservesPortExposureWithoutBroadeningHostIPBindings(t *testing.T) { + compose := write(t, "compose.yaml", `services: + web: + image: nginx:alpine + ports: + - "8080:80" + - "5432:5432" + - "127.0.0.1:8081:81" + - "0.0.0.0:82:82" + - "0.0.0.0:8084:84" + - "192.0.2.10:8083:83" +`) + + sf, warnings, err := stackfile.FromCompose(compose, "demo") + if err != nil { + t.Fatalf("FromCompose: %v", err) + } + + publicByPort := make(map[int32]bool) + for _, port := range sf.Resources["web"].Ports { + publicByPort[port.Port] = port.Public + } + want := map[int32]bool{ + 80: true, + 5432: true, + 81: false, + 82: true, + 84: true, + 83: false, + } + if !reflect.DeepEqual(publicByPort, want) { + t.Errorf("port exposure = %#v, want %#v", publicByPort, want) + } + if want := []string{"0.0.0.0:8084:84", "127.0.0.1:8081:81", "192.0.2.10:8083:83", "8080:80"}; !reflect.DeepEqual(warnings.UnsupportedPortMappings["web"], want) { + t.Errorf("port mapping warnings = %#v, want %#v", warnings.UnsupportedPortMappings["web"], want) + } +} + +func TestFromComposeMakesTCPExplicitAndRejectsUnsupportedUDP(t *testing.T) { + compose := write(t, "compose.yaml", `services: + web: + image: nginx:alpine + ports: + - "8080:80" + - "8443:443/tcp" + - "5353:5353/udp" + db: + image: postgres:16 + ports: + - "5432:5432" +`) + + sf, warnings, err := stackfile.FromCompose(compose, "demo") + if err != nil { + t.Fatalf("FromCompose: %v", err) + } + + webProtocols := make(map[int32]string) + for _, port := range sf.Resources["web"].Ports { + webProtocols[port.Port] = port.Protocol + } + if want := map[int32]string{80: "TCP", 443: "TCP"}; !reflect.DeepEqual(webProtocols, want) { + t.Errorf("web protocols = %#v, want explicit Compose TCP %#v", webProtocols, want) + } + dbPorts := sf.Resources["db"].Ports + if len(dbPorts) != 1 || dbPorts[0].Port != 5432 || !dbPorts[0].Public || dbPorts[0].Protocol != "TCP" { + t.Errorf("db ports = %#v, want public 5432/TCP", dbPorts) + } + if want := []string{"5353:5353/udp"}; !reflect.DeepEqual(warnings.UnsupportedPorts["web"], want) { + t.Errorf("unsupported ports = %#v, want %#v", warnings.UnsupportedPorts["web"], want) + } +} + +func TestFromComposePreservesListCommandBoundariesAndOmitsAmbiguousStrings(t *testing.T) { + compose := write(t, "compose.yaml", `services: + string-command: + image: busybox:latest + command: /bin/sh -c 'echo "hello world"' + list-command: + image: busybox:latest + command: + - /bin/sh + - -c + - echo "hello world" + entrypoint-and-command: + image: busybox:latest + entrypoint: + - /bin/sh + - -c + command: + - echo "hello world" + string-entrypoint: + image: busybox:latest + entrypoint: /bin/sh -c + empty-forms: + image: busybox:latest + entrypoint: [] + command: [] +`) + + sf, warnings, err := stackfile.FromCompose(compose, "demo") + if err != nil { + t.Fatalf("FromCompose: %v", err) + } + + stringCommand := sf.Resources["string-command"] + if stringCommand.Command != nil || stringCommand.Args != nil { + t.Errorf("ambiguous string command = command %#v args %#v, want omitted", stringCommand.Command, stringCommand.Args) + } + listCommand := sf.Resources["list-command"] + if listCommand.Command != nil { + t.Errorf("command-only list mapped to command %#v, want nil", listCommand.Command) + } + if want := []string{"/bin/sh", "-c", `echo "hello world"`}; !reflect.DeepEqual(listCommand.Args, want) { + t.Errorf("command-only args = %#v, want exact boundaries %#v", listCommand.Args, want) + } + combined := sf.Resources["entrypoint-and-command"] + if want := []string{"/bin/sh", "-c"}; !reflect.DeepEqual(combined.Command, want) { + t.Errorf("entrypoint command = %#v, want %#v", combined.Command, want) + } + if want := []string{`echo "hello world"`}; !reflect.DeepEqual(combined.Args, want) { + t.Errorf("entrypoint args = %#v, want %#v", combined.Args, want) + } + stringEntrypoint := sf.Resources["string-entrypoint"] + if stringEntrypoint.Command != nil || stringEntrypoint.Args != nil { + t.Errorf("ambiguous string entrypoint = command %#v args %#v, want omitted", stringEntrypoint.Command, stringEntrypoint.Args) + } + if want := []string{"command"}; !reflect.DeepEqual(warnings.UnsupportedCommandForms["string-command"], want) { + t.Errorf("string command warnings = %#v, want %#v", warnings.UnsupportedCommandForms["string-command"], want) + } + if want := []string{"entrypoint"}; !reflect.DeepEqual(warnings.UnsupportedCommandForms["string-entrypoint"], want) { + t.Errorf("string entrypoint warnings = %#v, want %#v", warnings.UnsupportedCommandForms["string-entrypoint"], want) + } + if _, ok := warnings.UnsupportedCommandForms["list-command"]; ok { + t.Errorf("exact list command unexpectedly warned: %#v", warnings.UnsupportedCommandForms["list-command"]) + } + if _, ok := warnings.UnsupportedCommandForms["entrypoint-and-command"]; ok { + t.Errorf("exact list entrypoint/command unexpectedly warned: %#v", warnings.UnsupportedCommandForms["entrypoint-and-command"]) + } + if want := []string{"command", "entrypoint"}; !reflect.DeepEqual(warnings.UnsupportedCommandForms["empty-forms"], want) { + t.Errorf("empty command-form warnings = %#v, want %#v", warnings.UnsupportedCommandForms["empty-forms"], want) + } +} diff --git a/internal/stackfile/json.go b/internal/stackfile/json.go index 9aed3a5..47015f7 100644 --- a/internal/stackfile/json.go +++ b/internal/stackfile/json.go @@ -4,8 +4,8 @@ import ( "encoding/json" "os" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" openapi "github.com/Stackdome/stackdome/pkg/api/openapi" - clierrors "github.com/stackdome/cli/internal/errors" ) func LoadJSON(path string) (*openapi.Stack, error) { diff --git a/internal/stackfile/load.go b/internal/stackfile/load.go index 7167b71..5e76496 100644 --- a/internal/stackfile/load.go +++ b/internal/stackfile/load.go @@ -1,7 +1,7 @@ // Package stackfile is the CLI's thin layer over the hub's canonical stackfile -// package: file loading with CLI-shaped errors, the `env_file` convenience the -// hub schema does not carry, the docker-compose converter, and raw stack JSON. -// Schema, validation and conversion live in the hub package — never fork them. +// package: file loading with CLI-shaped errors, docker-compose conversion, and +// raw stack JSON. Schema, validation and conversion live in the hub package — +// never fork them. package stackfile import ( @@ -9,10 +9,8 @@ import ( "path/filepath" "strings" + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" hub "github.com/Stackdome/stackdome/pkg/stackfile" - "github.com/joho/godotenv" - clierrors "github.com/stackdome/cli/internal/errors" - "gopkg.in/yaml.v3" ) type ( @@ -30,10 +28,11 @@ type ( var ( Validate = hub.Validate ResolveStack = hub.ResolveStack + FromStack = hub.FromStack + SchemaJSON = hub.SchemaJSON ) -// Load reads, validates and returns the stackfile at path. Any `env_file` -// references are resolved relative to the stackfile's directory. +// Load reads, validates and returns the stackfile at path. func Load(path string) (*Stackfile, error) { ext := strings.ToLower(filepath.Ext(path)) if ext != ".yaml" && ext != ".yml" { @@ -53,56 +52,5 @@ func Load(path string) (*Stackfile, error) { return nil, clierrors.ValidationError(err.Error()) } - if err := applyEnvFiles(sf, data, filepath.Dir(path)); err != nil { - return nil, err - } - return sf, nil } - -// applyEnvFiles merges each resource's `env_file` into its env map. The key is -// a CLI-only extension, so it is read off the raw YAML rather than the hub's -// Resource type. Values already set in `env:` win. -func applyEnvFiles(sf *Stackfile, data []byte, baseDir string) error { - var raw struct { - Resources map[string]struct { - EnvFile string `yaml:"env_file"` - } `yaml:"resources"` - } - if err := yaml.Unmarshal(data, &raw); err != nil { - return clierrors.Wrap(err, "Failed to parse stackfile") - } - - for name, r := range raw.Resources { - if r.EnvFile == "" { - continue - } - envPath := r.EnvFile - if !filepath.IsAbs(envPath) { - envPath = filepath.Join(baseDir, envPath) - } - fileEnv, err := godotenv.Read(envPath) - if err != nil { - return clierrors.Wrapf(err, "Failed to read env_file for resource %q", name) - } - - res, ok := sf.Resources[name] - if !ok { - continue - } - if res.Env == nil { - res.Env = make(map[string]string, len(fileEnv)) - } - for k, v := range fileEnv { - if v == "" { - continue - } - if _, exists := res.Env[k]; !exists { - res.Env[k] = v - } - } - sf.Resources[name] = res - } - - return nil -} diff --git a/internal/stackfile/load_test.go b/internal/stackfile/load_test.go index 38b450e..32cb673 100644 --- a/internal/stackfile/load_test.go +++ b/internal/stackfile/load_test.go @@ -3,11 +3,12 @@ package stackfile_test import ( "os" "path/filepath" + "reflect" "strings" "testing" + "github.com/Stackdome/stackdome-cli/internal/stackfile" "github.com/Stackdome/stackdome/pkg/models" - "github.com/stackdome/cli/internal/stackfile" "gopkg.in/yaml.v3" ) @@ -82,26 +83,65 @@ func TestSelfPublicURLOldSpellingRejected(t *testing.T) { } } -func TestLoadMergesEnvFile(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "web.env"), []byte("FROM_FILE=yes\nPUBLIC_URL=ignored\n"), 0o644); err != nil { - t.Fatal(err) - } - path := filepath.Join(dir, "stackfile.yaml") - if err := os.WriteFile(path, []byte(singlePortSelfRef+" env_file: web.env\n"), 0o644); err != nil { - t.Fatal(err) +func TestLoadRejectsUnknownKeysIncludingCLIOnlyEnvFile(t *testing.T) { + tests := []struct { + name string + content string + }{ + { + name: "top-level typo", + content: singlePortSelfRef + "nmae: typo\n", + }, + { + name: "cli-only env_file", + content: singlePortSelfRef + " env_file: web.env\n", + }, } - sf, err := stackfile.Load(path) - if err != nil { - t.Fatalf("load: %v", err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := stackfile.Load(write(t, "stackfile.yaml", tt.content)) + if err == nil { + t.Fatal("expected unknown key to be rejected") + } + }) } - env := sf.Resources["web"].Env - if env["FROM_FILE"] != "yes" { - t.Fatalf("env_file value not merged: %v", env) +} + +func TestLoadAllowsCommitWithBranchOrTagButNotBoth(t *testing.T) { + tests := []struct { + name string + build string + wantErr bool + }{ + { + name: "branch and commit", + build: " repo: https://github.com/example/app.git\n branch: main\n commit: deadbeef\n", + }, + { + name: "tag and commit", + build: " repo: https://github.com/example/app.git\n tag: v1.0.0\n commit: deadbeef\n", + }, + { + name: "branch and tag", + build: " repo: https://github.com/example/app.git\n branch: main\n tag: v1.0.0\n", + wantErr: true, + }, + { + name: "commit without ref", + build: " repo: https://github.com/example/app.git\n commit: deadbeef\n", + wantErr: true, + }, } - if env["PUBLIC_URL"] != "{{ self.public_url }}" { - t.Fatalf("env_file must not override env: %v", env) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content := "name: demo\nresources:\n app:\n build:\n" + tt.build + _, err := stackfile.Load(write(t, "stackfile.yaml", content)) + if (err != nil) != tt.wantErr { + t.Fatalf("Load() error = %v, want error %t", err, tt.wantErr) + } + }) } } @@ -124,12 +164,12 @@ volumes: db-data: `) - sf, envFiles, err := stackfile.FromCompose(compose, "demo") + sf, warnings, err := stackfile.FromCompose(compose, "demo") if err != nil { t.Fatalf("from compose: %v", err) } - if len(envFiles) != 0 { - t.Fatalf("unexpected env files: %v", envFiles) + if len(warnings.EnvFiles) != 0 || len(warnings.UnsupportedBindMounts) != 0 { + t.Fatalf("unexpected compose warnings: %+v", warnings) } out, err := yaml.Marshal(sf) @@ -140,3 +180,65 @@ volumes: t.Fatalf("generated stackfile does not validate: %v\n%s", err, out) } } + +func TestFromComposeCollectsEveryEnvFileSyntax(t *testing.T) { + compose := write(t, "compose.yaml", `services: + string: + image: nginx:alpine + env_file: .env.string + list: + image: nginx:alpine + env_file: + - .env.base + - .env.override + mapping: + image: nginx:alpine + env_file: + path: .env.mapping + required: false + long-list: + image: nginx:alpine + env_file: + - path: .env.first + required: true + - path: .env.second + format: raw +`) + + _, warnings, err := stackfile.FromCompose(compose, "demo") + if err != nil { + t.Fatalf("from compose: %v", err) + } + want := map[string][]string{ + "string": {".env.string"}, + "list": {".env.base", ".env.override"}, + "mapping": {".env.mapping"}, + "long-list": {".env.first", ".env.second"}, + } + if !reflect.DeepEqual(warnings.EnvFiles, want) { + t.Fatalf("env files = %#v, want %#v", warnings.EnvFiles, want) + } +} + +func TestFromComposeDoesNotCreateNamedVolumeFromWindowsBindMount(t *testing.T) { + compose := write(t, "compose.yaml", `services: + web: + image: nginx:alpine + volumes: + - 'C:\data:/data' +`) + + sf, warnings, err := stackfile.FromCompose(compose, "demo") + if err != nil { + t.Fatalf("from compose: %v", err) + } + if _, exists := sf.Volumes["C"]; exists { + t.Fatalf("Windows bind mount became bogus named volume C: %+v", sf.Volumes) + } + if mounts := sf.Resources["web"].Volumes; len(mounts) != 0 { + t.Fatalf("Windows bind mount became bogus resource mount: %+v", mounts) + } + if got := warnings.UnsupportedBindMounts["web"]; !reflect.DeepEqual(got, []string{`C:\data`}) { + t.Fatalf("unsupported bind mount warning = %v, want C:\\data", got) + } +}