diff --git a/docs/json-output.md b/docs/json-output.md new file mode 100644 index 00000000..f552c529 --- /dev/null +++ b/docs/json-output.md @@ -0,0 +1,72 @@ +# JSON output (`--output-json`) — the scripting contract + +One page: which commands emit machine-readable JSON, what the output +promises, and what a script may rely on. + +## Which commands emit JSON + +| Command | Success statuses | Notes | +|---|---|---| +| `version` | — (plain payload, no `status` field) | `version`, `git_sha`, `build_date`, `go_version`, `platform` | +| `data ingest` | `succeeded` · `dry-run` · `detached` · `completed_with_failures` · `failed` · `unknown` · `auth_error` · `submit_error` · `watch_error` | result includes the ingest summary (row counts, success rate) when one was produced | +| `data list` | — (a listing, no `status` field) | `namespace`, `release`, `count`, `datasets` | +| `data delete` | `deleted` · `dry-run` · `declined` | result includes `database`, `table` (the case-resolved spelling), `pvc_paths`, `removed_paths`. Never prompts — pass `--yes` (or `--dry-run`) | + +Not covered (yet): `doctor`, `resources`, `auth status` — extending +`--output-json` to the read-only diagnostics is deferred pending the +epic's OQ5 decision. `auth status --check` is exit-code-only by design. + +## The contract + +1. **stdout carries exactly one JSON object per run — nothing else.** + All human-facing output (banners, progress, hints) goes to stderr in + `--output-json` mode. `… --output-json | jq .` always works. +2. **Exit codes are in lockstep and unchanged.** `--output-json` never + alters a command's documented exit codes; the JSON is additive. + A non-zero exit always comes with `status: "error"`, and the + `exit_code` field always equals the process exit code. +3. **Failures still emit JSON.** Any failure — before or after the + command started doing work — writes the error object below, so a + parser never sees empty stdout. +4. **Safe endings are exit 0 — branch on `status`.** A dry run, a + declined confirmation, and a real deletion all exit 0 (matching the + human flow); the `status` field is what distinguishes them. Scripts + that need "it actually happened" must check `status`, not just the + exit code. +5. **Arrays are never `null`.** Empty lists marshal as `[]` + (`datasets`, `pvc_paths`, `removed_paths`), so indexing is safe. +6. **`--output-json` implies non-interactive.** Commands never prompt + in JSON mode: `data ingest` treats it as `--no-input`; `data delete` + requires an explicit `--yes` (or `--dry-run`) and otherwise fails + closed (exit 3). + +## The error shape + +Identical across every JSON-emitting command: + +```json +{ + "status": "error", + "error": "", + "exit_code": 7 +} +``` + +## Stability promise + +- **Additive evolution only.** New fields may appear in any release; + existing fields are not renamed, removed, or re-typed. Parse + tolerantly (ignore unknown fields). +- **Status vocabularies may grow.** Treat an unrecognized `status` as + "not the success you were looking for", not as an error in your + parser. +- **Formatting is not part of the contract.** Output is currently + indented JSON; scripts must parse it as JSON, not scrape lines. +- **Breaking changes** (renaming/removing a field, changing a type, + repurposing a status) require a major version bump and will be called + out in the release notes. + +The shapes are owned by the CLI presentation layer +(`internal/cli/*.go`: `versionPayload`, `pushJSONResult`, +`dataListJSON`, `dataDeleteJSON`) — internal types stay JSON-tag-free +so this wire format can evolve deliberately. diff --git a/internal/cli/data_delete.go b/internal/cli/data_delete.go index d306157f..890e852d 100644 --- a/internal/cli/data_delete.go +++ b/internal/cli/data_delete.go @@ -2,8 +2,10 @@ package cli import ( "context" + "encoding/json" "errors" "fmt" + "io" "strings" "github.com/spf13/cobra" @@ -26,6 +28,11 @@ type runDataDeleteArgs struct { Yes bool Printer *ui.Printer Prompter prompter // nil off a TTY or when --yes is set + // OutputJSON routes human output to stderr and emits exactly one JSON + // result object to JSONOut (stdout); set together by the RunE in + // --output-json mode. Same contract as data list / data ingest. + OutputJSON bool + JSONOut io.Writer } // newDataDeleteCmd implements `tracebloc data delete ` — the @@ -42,6 +49,7 @@ func newDataDeleteCmd() *cobra.Command { nsOverride string dryRun bool yes bool + outputJSON bool ) cmd := &cobra.Command{ @@ -63,14 +71,29 @@ Exit codes: 4 cluster reachable but no tracebloc client / shared storage missing, or the client's dataset list couldn't be read (can't confirm the target) 5 no dataset by that name on this client (nothing to delete) - 7 teardown failed mid-flight (table drop or PVC rm errored)`, + 7 teardown failed mid-flight (table drop or PVC rm errored) + +With --output-json, stdout carries exactly one JSON result object per run +(human output goes to stderr) and the exit codes above are unchanged; see +docs/json-output.md for the shape and the stability promise.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Confirm interactively on a TTY unless --yes was passed. + // --output-json never prompts (same as data ingest's + // implies---no-input): a scripted delete must say --yes + // (or --dry-run) explicitly. var pr prompter - if !yes && isInteractiveTTY() { + if !yes && !outputJSON && isInteractiveTTY() { pr = surveyPrompter{} } + // In --output-json mode, human output goes to stderr so + // stdout carries only the JSON — same split as data list. + printer := printerFor(cmd) + var jsonOut io.Writer + if outputJSON { + printer = printerForWriter(cmd, cmd.ErrOrStderr()) + jsonOut = cmd.OutOrStdout() + } return runDataDelete(cmd.Context(), runDataDeleteArgs{ Table: args[0], Kubeconfig: kubeconfigPath, @@ -78,8 +101,10 @@ Exit codes: Namespace: nsOverride, DryRun: dryRun, Yes: yes, - Printer: printerFor(cmd), + Printer: printer, Prompter: pr, + OutputJSON: outputJSON, + JSONOut: jsonOut, }) }, } @@ -90,6 +115,8 @@ Exit codes: "show what would be deleted without deleting anything") cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip the confirmation prompt (required when not on a terminal)") + cmd.Flags().BoolVar(&outputJSON, "output-json", false, + "emit the delete result as JSON on stdout (human output → stderr; never prompts — pass --yes to delete, or --dry-run)") return cmd } @@ -98,7 +125,24 @@ Exit codes: // then removes the in-cluster artifacts. The flow mirrors runDataIngest // (validate → discover → plan/pre-flight → act) so the two commands feel // like siblings. -func runDataDelete(ctx context.Context, a runDataDeleteArgs) error { +func runDataDelete(ctx context.Context, a runDataDeleteArgs) (err error) { + // In --output-json mode, guarantee stdout always carries JSON: the + // terminal paths (deleted / dry-run / declined) emit a result and set + // jsonEmitted; this defer covers every failure return (bad name, + // kubeconfig, no release, refused, teardown) with a JSON error + // object, mirroring data list. (Bugbot #53) + jsonEmitted := false + defer func() { + if a.OutputJSON && err != nil && !jsonEmitted { + code := 1 + var ee *exitError + if errors.As(err, &ee) { + code = ee.Code() + } + writeDataDeleteErrorJSON(a.JSONOut, err, code) + } + }() + p := a.Printer p.Banner("tracebloc", "delete an ingested dataset") p.Para(`This permanently removes a dataset you ingested earlier: it drops the table from @@ -167,11 +211,18 @@ undone — re-ingesting the data is the only way back.`) if a.DryRun { p.Newline() p.Successf("Dry-run — nothing was deleted.") + if a.OutputJSON { + writeDataDeleteJSON(a.JSONOut, "dry-run", resolved.Namespace, release.ReleaseName, plan, nil) + jsonEmitted = true + } return nil } // 6. Confirm. --yes skips; off a TTY without --yes we refuse rather - // than delete unprompted. + // than delete unprompted. (In --output-json mode the RunE never + // wires a Prompter, so a JSON run without --yes lands on the + // refusal above via exit 3 — but if a caller passes one anyway, + // a decline still keeps the stdout-always-JSON contract.) if !a.Yes { if a.Prompter == nil { return &exitError{code: 3, err: errors.New( @@ -182,12 +233,20 @@ undone — re-ingesting the data is the only way back.`) if err != nil { if errors.Is(err, errInteractiveCancelled) { p.Infof("Cancelled — nothing was deleted.") + if a.OutputJSON { + writeDataDeleteJSON(a.JSONOut, "declined", resolved.Namespace, release.ReleaseName, plan, nil) + jsonEmitted = true + } return nil } return &exitError{code: 3, err: err} } if !ok { p.Infof("Cancelled — nothing was deleted.") + if a.OutputJSON { + writeDataDeleteJSON(a.JSONOut, "declined", resolved.Namespace, release.ReleaseName, plan, nil) + jsonEmitted = true + } return nil } } @@ -223,9 +282,71 @@ undone — re-ingesting the data is the only way back.`) p.Newline() p.Successf("Deleted %s.%s and %d PVC path(s).", plan.Database, plan.Table, len(res.RemovedPaths)) p.Infof("The dataset's catalog metadata is kept as a record on tracebloc, marked unavailable — never removed.") + if a.OutputJSON { + writeDataDeleteJSON(a.JSONOut, "deleted", resolved.Namespace, release.ReleaseName, plan, res.RemovedPaths) + jsonEmitted = true + } return nil } +// dataDeleteJSON is the --output-json shape (owned by the CLI layer, the +// same convention as dataListJSON / pushJSONResult — see +// docs/json-output.md for the cross-command contract). +type dataDeleteJSON struct { + Status string `json:"status"` // deleted | dry-run | declined + Namespace string `json:"namespace"` + Release string `json:"release"` + Database string `json:"database"` + Table string `json:"table"` // the REAL (case-resolved) spelling, not the raw argument + PVCPaths []string `json:"pvc_paths"` + RemovedPaths []string `json:"removed_paths"` +} + +// writeDataDeleteJSON serializes the delete result to w (stdout in +// --output-json mode). Marshal errors are dropped: marshaling our own +// struct can't fail in practice, and the exit code remains the contract. +func writeDataDeleteJSON(w io.Writer, status, namespace, release string, plan push.TeardownPlan, removed []string) { + pvcPaths := plan.PVCPaths + if pvcPaths == nil { + pvcPaths = []string{} // emit [] not null + } + if removed == nil { + removed = []string{} // emit [] not null + } + res := dataDeleteJSON{ + Status: status, + Namespace: namespace, + Release: release, + Database: plan.Database, + Table: plan.Table, + PVCPaths: pvcPaths, + RemovedPaths: removed, + } + b, err := json.MarshalIndent(res, "", " ") + if err != nil { + return + } + _, _ = fmt.Fprintln(w, string(b)) +} + +// writeDataDeleteErrorJSON emits a minimal JSON error object for +// --output-json runs that fail before a result is produced, so stdout +// is never empty on failure. The shape mirrors writeDataListErrorJSON +// EXACTLY ({status:"error", error, exit_code}) — the cross-command +// error contract documented in docs/json-output.md. +func writeDataDeleteErrorJSON(w io.Writer, e error, code int) { + res := struct { + Status string `json:"status"` + Error string `json:"error"` + ExitCode int `json:"exit_code"` + }{Status: "error", Error: e.Error(), ExitCode: code} + b, err := json.MarshalIndent(res, "", " ") + if err != nil { + return + } + _, _ = fmt.Fprintln(w, string(b)) +} + // resolveDeleteTarget maps the user-supplied dataset name onto the REAL // spelling of a dataset that actually exists on the client, matching // case-INSENSITIVELY exactly as `data ingest`'s destination guard does diff --git a/internal/cli/data_delete_json_test.go b/internal/cli/data_delete_json_test.go new file mode 100644 index 00000000..33ab4a24 --- /dev/null +++ b/internal/cli/data_delete_json_test.go @@ -0,0 +1,233 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/rest" + + "github.com/tracebloc/cli/internal/cluster" + "github.com/tracebloc/cli/internal/push" + "github.com/tracebloc/cli/internal/ui" +) + +// TestRunDataDelete_OutputJSON pins the `data delete --output-json` +// contract (cli#297), mirroring data list's: stdout carries exactly one +// JSON object per run (human output stays on the Printer), the exit +// codes are unchanged, and every failure return still emits +// {status:"error", error, exit_code} via the deferred writer — so +// `… --output-json | jq` never sees empty stdout. +// +// Terminal statuses covered: deleted / dry-run / declined (all exit 0 — +// scripts must branch on status, not just the exit code), plus early +// (exit 2, before discovery) and late (exit 7, mid-teardown) failures. +func TestRunDataDelete_OutputJSON(t *testing.T) { + origRCT, origList, origTD := resolveClusterTargetFn, listDatasetsFn, teardownFn + t.Cleanup(func() { + resolveClusterTargetFn, listDatasetsFn, teardownFn = origRCT, origList, origTD + }) + + resolveClusterTargetFn = func(_ context.Context, _ *ui.Printer, _ cluster.KubeconfigOptions, _ activeClientBinding, _ bool) (*clusterTarget, error) { + return &clusterTarget{ + Resolved: &cluster.ResolvedConfig{Context: "ctx", Namespace: "tracebloc"}, + Clientset: fake.NewSimpleClientset(), + Release: &cluster.ParentRelease{ReleaseName: "tracebloc", IngestorSAName: "tracebloc-ingestor"}, + PVC: &cluster.SharedPVC{ClaimName: "client-pvc", MountPath: "/data/shared"}, + }, nil + } + listDatasetsFn = func(_ context.Context, _ kubernetes.Interface, _ *rest.Config, _ string) ([]string, error) { + return []string{"churn"}, nil + } + teardownFn = func(_ context.Context, _ kubernetes.Interface, _ push.Executor, _ string, plan push.TeardownPlan, _ push.PodSpecOptions) (push.TeardownResult, error) { + return push.TeardownResult{DroppedTable: true, RemovedPaths: plan.PVCPaths}, nil + } + + run := func(a runDataDeleteArgs) (dataDeleteJSON, string, string, error) { + var jsonBuf, human bytes.Buffer + a.OutputJSON = true + a.JSONOut = &jsonBuf + a.Printer = ui.New(&human, ui.WithColor(false)) + err := runDataDelete(context.Background(), a) + var got dataDeleteJSON + if jsonBuf.Len() > 0 { + if uerr := json.Unmarshal(jsonBuf.Bytes(), &got); uerr != nil { + t.Fatalf("stdout is not a single JSON object: %v\n%s", uerr, jsonBuf.String()) + } + } + return got, jsonBuf.String(), human.String(), err + } + + t.Run("success -> status deleted, real spelling, exit 0", func(t *testing.T) { + // Mixed case in: the JSON must carry the case-resolved table name + // (backend#1027), not the raw argument. + got, raw, human, err := run(runDataDeleteArgs{Table: "Churn", Yes: true}) + if err != nil { + t.Fatalf("want nil error (exit 0), got %v", err) + } + if got.Status != "deleted" || got.Table != "churn" || got.Database == "" { + t.Errorf("want status=deleted table=churn (case-resolved) + database, got %+v", got) + } + if got.Namespace != "tracebloc" || got.Release != "tracebloc" { + t.Errorf("want namespace/release from the resolved target, got %+v", got) + } + if len(got.RemovedPaths) == 0 || len(got.PVCPaths) == 0 { + t.Errorf("want pvc_paths + removed_paths populated, got %+v", got) + } + if !strings.Contains(human, "Deleted") { + t.Errorf("human output should still narrate on its own stream:\n%s", human) + } + if strings.Contains(raw, "Deleted ") { + t.Errorf("human copy leaked into the JSON stream:\n%s", raw) + } + }) + + t.Run("dry-run -> status dry-run, nothing removed, exit 0", func(t *testing.T) { + got, _, _, err := run(runDataDeleteArgs{Table: "churn", DryRun: true}) + if err != nil { + t.Fatalf("want nil error, got %v", err) + } + if got.Status != "dry-run" { + t.Errorf("want status=dry-run, got %+v", got) + } + if len(got.RemovedPaths) != 0 { + t.Errorf("dry-run must not report removed paths, got %+v", got.RemovedPaths) + } + // [] not null — a script indexing removed_paths must not explode. + if _, raw, _, _ := run(runDataDeleteArgs{Table: "churn", DryRun: true}); !strings.Contains(raw, `"removed_paths": []`) { + t.Errorf("nil removed paths should marshal as []:\n%s", raw) + } + }) + + t.Run("confirm declined -> status declined, exit 0", func(t *testing.T) { + no := false + got, _, _, err := run(runDataDeleteArgs{Table: "churn", Prompter: &fakePrompter{confirm: &no}}) + if err != nil { + t.Fatalf("declining is a safe exit 0, got %v", err) + } + if got.Status != "declined" { + t.Errorf("want status=declined, got %+v", got) + } + if len(got.RemovedPaths) != 0 { + t.Errorf("a declined delete must not report removed paths, got %+v", got.RemovedPaths) + } + }) + + t.Run("early failure (invalid name, exit 2) -> error JSON via the defer", func(t *testing.T) { + var jsonBuf bytes.Buffer + err := runDataDelete(context.Background(), runDataDeleteArgs{ + Table: "bad name!", + Yes: true, + OutputJSON: true, + JSONOut: &jsonBuf, + Printer: ui.New(&bytes.Buffer{}, ui.WithColor(false)), + }) + var ee *exitError + if !errors.As(err, &ee) || ee.Code() != 2 { + t.Fatalf("err = %v, want *exitError code 2", err) + } + var got map[string]any + if e := json.Unmarshal(jsonBuf.Bytes(), &got); e != nil { + t.Fatalf("stdout is not JSON on failure: %v\n%s", e, jsonBuf.String()) + } + if got["status"] != "error" || got["exit_code"] != float64(2) || got["error"] == "" { + t.Errorf("got %+v, want status=error exit_code=2 + message", got) + } + }) + + t.Run("refused off-TTY without --yes (exit 3) -> error JSON", func(t *testing.T) { + // This is what a scripted --output-json run without --yes hits: + // the RunE wires no Prompter in JSON mode, so the refusal guard fires. + var jsonBuf bytes.Buffer + err := runDataDelete(context.Background(), runDataDeleteArgs{ + Table: "churn", + OutputJSON: true, + JSONOut: &jsonBuf, + Printer: ui.New(&bytes.Buffer{}, ui.WithColor(false)), + }) + var ee *exitError + if !errors.As(err, &ee) || ee.Code() != 3 { + t.Fatalf("err = %v, want *exitError code 3", err) + } + var got map[string]any + if e := json.Unmarshal(jsonBuf.Bytes(), &got); e != nil { + t.Fatalf("stdout is not JSON on refusal: %v\n%s", e, jsonBuf.String()) + } + if got["status"] != "error" || got["exit_code"] != float64(3) { + t.Errorf("got %+v, want status=error exit_code=3", got) + } + }) + + t.Run("late failure (teardown, exit 7) -> error JSON, no success object", func(t *testing.T) { + teardownFn = func(_ context.Context, _ kubernetes.Interface, _ push.Executor, _ string, _ push.TeardownPlan, _ push.PodSpecOptions) (push.TeardownResult, error) { + return push.TeardownResult{}, errors.New("could not reach the mysql pod") + } + t.Cleanup(func() { + teardownFn = func(_ context.Context, _ kubernetes.Interface, _ push.Executor, _ string, plan push.TeardownPlan, _ push.PodSpecOptions) (push.TeardownResult, error) { + return push.TeardownResult{DroppedTable: true, RemovedPaths: plan.PVCPaths}, nil + } + }) + var jsonBuf bytes.Buffer + err := runDataDelete(context.Background(), runDataDeleteArgs{ + Table: "churn", + Yes: true, + OutputJSON: true, + JSONOut: &jsonBuf, + Printer: ui.New(&bytes.Buffer{}, ui.WithColor(false)), + }) + var ee *exitError + if !errors.As(err, &ee) || ee.Code() != 7 { + t.Fatalf("err = %v, want *exitError code 7", err) + } + var got map[string]any + if e := json.Unmarshal(jsonBuf.Bytes(), &got); e != nil { + t.Fatalf("stdout is not JSON on teardown failure: %v\n%s", e, jsonBuf.String()) + } + if got["status"] != "error" || got["exit_code"] != float64(7) { + t.Errorf("got %+v, want status=error exit_code=7", got) + } + }) +} + +// TestDataDeleteCmd_OutputJSONNeverPrompts pins the RunE wiring: in +// --output-json mode no Prompter is created even on what looks like a +// TTY, so a scripted run can never hang on a survey prompt — it either +// has --yes/--dry-run or fails closed with exit 3 (asserted above). +// Wiring-level, so it goes through the cobra command, not runDataDelete. +func TestDataDeleteCmd_OutputJSONNeverPrompts(t *testing.T) { + origList := listDatasetsFn + t.Cleanup(func() { listDatasetsFn = origList }) + + cmd := newDataDeleteCmd() + // The production root command silences cobra's usage/error echo + // (root.go); mirror that here so stdout carries only what the + // command itself writes. + cmd.SilenceUsage = true + cmd.SilenceErrors = true + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{"churn", "--output-json", "--kubeconfig", "/nonexistent/kubeconfig"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("want a kubeconfig failure, got nil") + } + // stdout must be the JSON error object — nothing else. + var got map[string]any + if e := json.Unmarshal(out.Bytes(), &got); e != nil { + t.Fatalf("stdout is not JSON: %v\n%s", e, out.String()) + } + if got["status"] != "error" { + t.Errorf("got %+v, want status=error", got) + } + // The human banner went to stderr, not stdout. + if strings.Contains(out.String(), "tracebloc") && !strings.Contains(out.String(), `"error"`) { + t.Errorf("human output leaked to stdout:\n%s", out.String()) + } +}