Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions docs/json-output.md
Original file line numberDiff line numberDiff line change
@@ -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": "<human-readable message>",
"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.
131 changes: 126 additions & 5 deletions internal/cli/data_delete.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,8 +2,10 @@ package cli

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"

"github.com/spf13/cobra"
Expand All@@ -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 <table>` — the
Expand All@@ -42,6 +49,7 @@ func newDataDeleteCmd() *cobra.Command {
nsOverride string
dryRun bool
yes bool
outputJSON bool
)

cmd := &cobra.Command{
Expand All@@ -63,23 +71,40 @@ 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,
Context: contextOverride,
Namespace: nsOverride,
DryRun: dryRun,
Yes: yes,
Printer: printerFor(cmd),
Printer: printer,
Prompter: pr,
OutputJSON: outputJSON,
JSONOut: jsonOut,
})
},
}
Expand All@@ -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
}
Expand All@@ -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
Expand DownExpand Up@@ -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(
Expand All@@ -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
}
}
Expand DownExpand Up@@ -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
Expand Down
Loading
Loading