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
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ module github.com/tracebloc/cli
go 1.26.0

require (
github.com/fatih/color v1.19.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/schollz/progressbar/v3 v3.19.0
github.com/spf13/cobra v1.8.1
Expand All @@ -35,6 +36,8 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/moby/spdystream v0.5.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
Expand All @@ -47,7 +50,7 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
Expand Down
11 changes: 9 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
Expand Down Expand Up @@ -48,6 +50,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
Expand Down Expand Up @@ -100,8 +106,9 @@ golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
Expand Down
48 changes: 21 additions & 27 deletions internal/cli/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/spf13/cobra"

"github.com/tracebloc/cli/internal/cluster"
"github.com/tracebloc/cli/internal/ui"
)

// newClusterCmd wires the `tracebloc cluster` subtree. Today it has
Expand Down Expand Up @@ -86,7 +87,7 @@ Exit codes:
RunE: func(cmd *cobra.Command, _ []string) error {
return runClusterInfo(
cmd.Context(),
cmd.OutOrStdout(),
printerFor(cmd),
kubeconfigPath, contextOverride, nsOverride,
ingestorSAName,
tokenExpiry,
Expand All @@ -111,7 +112,7 @@ Exit codes:

func runClusterInfo(
ctx context.Context,
out interface{ Write([]byte) (int, error) },
p *ui.Printer,
kubeconfigPath, contextOverride, nsOverride string,
ingestorSAOverride string,
tokenExpiry int64,
Expand All @@ -133,16 +134,10 @@ func runClusterInfo(
return &exitError{code: 3, err: err}
}

// Explicit-discard the writer errors: same rationale as
// internal/cli/ingest.go — the exit code is the contract, and a
// pipe-write failure shouldn't convert success into failure. If
// the downstream consumer disappears mid-write we still finish
// cleanly. errcheck wants this acknowledged.
_, _ = fmt.Fprintf(out, "Kubeconfig:\n")
_, _ = fmt.Fprintf(out, " context: %s\n", resolved.Context)
_, _ = fmt.Fprintf(out, " server: %s\n", resolved.ServerURL)
_, _ = fmt.Fprintf(out, " namespace: %s\n", resolved.Namespace)
_, _ = fmt.Fprintln(out)
p.Section("Kubeconfig")
p.Field("context", resolved.Context)
p.Field("server", resolved.ServerURL)
p.Field("namespace", resolved.Namespace)

// Discover the parent release.
release, err := cluster.DiscoverParentRelease(ctx, cs, resolved.Namespace)
Expand All @@ -161,18 +156,17 @@ func runClusterInfo(
release.IngestorSAName = ingestorSAOverride
}

_, _ = fmt.Fprintf(out, "Parent release:\n")
_, _ = fmt.Fprintf(out, " name: %s\n", release.ReleaseName)
_, _ = fmt.Fprintf(out, " chart version: %s\n", release.ChartVersion)
_, _ = fmt.Fprintf(out, " app version: %s\n", release.AppVersion)
_, _ = fmt.Fprintf(out, " jobs-manager: %s\n", release.JobsManagerService)
_, _ = fmt.Fprintf(out, " ingestor SA: %s/%s\n", resolved.Namespace, release.IngestorSAName)
p.Section("Parent release")
p.Field("name", release.ReleaseName)
p.Field("chart version", release.ChartVersion)
p.Field("app version", release.AppVersion)
p.Field("jobs-manager", release.JobsManagerService)
p.Field("ingestor SA", fmt.Sprintf("%s/%s", resolved.Namespace, release.IngestorSAName))
digest := release.IngestorImageDigest
if digest == "" {
digest = "<not configured — admin must set images.ingestor.digest>"
}
_, _ = fmt.Fprintf(out, " ingestor img: %s\n", digest)
_, _ = fmt.Fprintln(out)
p.Field("ingestor img", digest)

// Mint a token (or fall back). The audience is intentionally
// nil today — jobs-manager's TokenReview accepts the default
Expand All @@ -187,16 +181,16 @@ func runClusterInfo(
}

hash := sha256.Sum256([]byte(tok.Token))
_, _ = fmt.Fprintf(out, "Ingestor SA token:\n")
_, _ = fmt.Fprintf(out, " source: %s\n", tok.Source)
_, _ = fmt.Fprintf(out, " sha256[:8]: %s\n", hex.EncodeToString(hash[:8]))
p.Section("Ingestor SA token")
p.Field("source", tok.Source.String())
p.Field("sha256[:8]", hex.EncodeToString(hash[:8]))
if tok.ExpirationSeconds > 0 {
exp := time.Duration(tok.ExpirationSeconds) * time.Second
_, _ = fmt.Fprintf(out, " expires in: ~%s (server may cap shorter)\n", exp)
p.Field("expires in", fmt.Sprintf("~%s (server may cap shorter)", exp))
} else {
_, _ = fmt.Fprintf(out, " expires in: never (static-secret fallback)\n")
p.Field("expires in", "never (static-secret fallback)")
}
_, _ = fmt.Fprintln(out)
_, _ = fmt.Fprintln(out, "Ready for `tracebloc dataset push`.")

p.Successf("Ready for `tracebloc dataset push`.")
return nil
}
6 changes: 4 additions & 2 deletions internal/cli/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/tracebloc/cli/internal/cluster"
"github.com/tracebloc/cli/internal/push"
"github.com/tracebloc/cli/internal/ui"
)

// TestPrintPushPreflight_RendersKeyFacts pins that the pre-flight
Expand Down Expand Up @@ -46,7 +47,8 @@ func TestPrintPushPreflight_RendersKeyFacts(t *testing.T) {
}

var buf bytes.Buffer
printPushPreflight(&buf, layout, release, pvc, spec, false)
p := ui.New(&buf, ui.WithColor(false))
printPushPreflight(p, layout, release, pvc, spec, false)
out := buf.String()

for _, want := range []string{
Expand Down Expand Up @@ -92,7 +94,7 @@ func TestRunClusterInfo_BadKubeconfigExitsThree(t *testing.T) {
}

var buf bytes.Buffer
err := runClusterInfo(context.Background(), &buf, bad, "", "", "", 600)
err := runClusterInfo(context.Background(), ui.New(&buf), bad, "", "", "", 600)
if err == nil {
t.Fatal("runClusterInfo with a broken kubeconfig returned nil; want an exitError")
}
Expand Down
74 changes: 35 additions & 39 deletions internal/cli/dataset.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/tracebloc/cli/internal/push"
"github.com/tracebloc/cli/internal/schema"
"github.com/tracebloc/cli/internal/submit"
"github.com/tracebloc/cli/internal/ui"
)

// newDatasetCmd wires the `tracebloc dataset` subtree. The dominant
Expand Down Expand Up @@ -166,6 +167,7 @@ Exit codes:
Detach: detach,
IdempotencyKey: idempotencyKey,
ImageDigest: imageDigest,
Printer: printerFor(cmd),
})
},
}
Expand Down Expand Up @@ -244,6 +246,10 @@ type runDatasetPushArgs struct {
IngestorSAName string
StagePodImage string

// Printer renders the pre-flight summary + status output. Built in
// the RunE from the persistent --plain flag (see printerFor).
Printer *ui.Printer

// Phase 4 (#152) fields. See the flag declarations for the
// per-knob rationale; all three are optional.
Detach bool
Expand Down Expand Up @@ -471,7 +477,7 @@ func runDatasetPush(ctx context.Context, out, errOut io.Writer, a runDatasetPush
// differs. Customers iterating on a bad layout see this
// every attempt, so it's worth keeping skimmable: one fact
// per line, aligned by column.
printPushPreflight(out, layout, release, pvc, spec, a.DryRun)
printPushPreflight(a.Printer, layout, release, pvc, spec, a.DryRun)

// 8. Dry-run stop. Acknowledged success.
if a.DryRun {
Expand Down Expand Up @@ -622,76 +628,66 @@ func runDatasetPush(ctx context.Context, out, errOut io.Writer, a runDatasetPush
// types) because the formatting is policy and lives with the CLI,
// not the data.
func printPushPreflight(
out io.Writer,
p *ui.Printer,
layout *push.LocalLayout,
release *cluster.ParentRelease,
pvc *cluster.SharedPVC,
spec map[string]any,
dryRun bool,
) {
// Explicit-discard the writer errors throughout — same rationale
// as cli/cluster.go and cli/ingest.go: a pipe-write failure
// shouldn't convert success into failure. The exit code is
// the contract.
cat, _ := spec["category"].(string)

_, _ = fmt.Fprintf(out, "Local dataset:\n")
_, _ = fmt.Fprintf(out, " root: %s\n", layout.Root)
p.Section("Local dataset")
p.Field("root", layout.Root)
switch {
case push.IsTabular(cat):
_, _ = fmt.Fprintf(out, " data CSV: %s\n", layout.LabelsCSV)
p.Field("data CSV", layout.LabelsCSV)
if sch, ok := spec["schema"].(map[string]string); ok {
_, _ = fmt.Fprintf(out, " columns: %d\n", len(sch))
p.Field("columns", fmt.Sprintf("%d", len(sch)))
}
case push.IsText(cat):
dir := push.TextSidecarDir(cat)
_, _ = fmt.Fprintf(out, " labels.csv: %s\n", layout.LabelsCSV)
_, _ = fmt.Fprintf(out, " %-15s%d files\n", dir+":", len(layout.Sidecars[dir]))
p.Field("labels.csv", layout.LabelsCSV)
p.Field(dir, fmt.Sprintf("%d files", len(layout.Sidecars[dir])))
if _, ok := layout.ExtraFiles["tokenizer.json"]; ok {
_, _ = fmt.Fprintf(out, " %-15s%s\n", "tokenizer:", "tokenizer.json")
p.Field("tokenizer", "tokenizer.json")
}
default:
_, _ = fmt.Fprintf(out, " labels.csv: %s\n", layout.LabelsCSV)
_, _ = fmt.Fprintf(out, " images: %d files\n", len(layout.Images))
p.Field("labels.csv", layout.LabelsCSV)
p.Field("images", fmt.Sprintf("%d files", len(layout.Images)))
if anns := layout.Sidecars["annotations"]; len(anns) > 0 {
_, _ = fmt.Fprintf(out, " annotations: %d files\n", len(anns))
p.Field("annotations", fmt.Sprintf("%d files", len(anns)))
}
}
_, _ = fmt.Fprintf(out, " total size: %s\n", push.HumanBytes(layout.TotalBytes))
_, _ = fmt.Fprintln(out)
p.Field("total size", push.HumanBytes(layout.TotalBytes))

_, _ = fmt.Fprintf(out, "Target cluster:\n")
_, _ = fmt.Fprintf(out, " release: %s (chart %s)\n", release.ReleaseName, release.ChartVersion)
_, _ = fmt.Fprintf(out, " jobs-manager: %s\n", release.JobsManagerService)
_, _ = fmt.Fprintf(out, " shared PVC: %s (%s)\n", pvc.ClaimName, pvc.Phase)
p.Section("Target cluster")
p.Field("release", fmt.Sprintf("%s (chart %s)", release.ReleaseName, release.ChartVersion))
p.Field("jobs-manager", release.JobsManagerService)
p.Field("shared PVC", fmt.Sprintf("%s (%s)", pvc.ClaimName, pvc.Phase))
if !pvc.IsReadWriteMany() {
// Warn but don't block — RWO clusters still work, the
// scheduler will co-locate the stage Pod with the existing
// mounter. Phase 3 PR-b will surface the same warning at
// pod-create time too.
_, _ = fmt.Fprintf(out, " access: %v (warn: not ReadWriteMany — stage Pod will co-locate)\n", pvc.AccessModes)
// Warn but don't block — RWO clusters still work; the scheduler
// co-locates the stage Pod with the existing mounter.
p.Warnf("PVC is %v, not ReadWriteMany — the stage Pod will co-locate with the existing mounter", pvc.AccessModes)
}
_, _ = fmt.Fprintln(out)

_, _ = fmt.Fprintf(out, "Synthesized ingest spec:\n")
_, _ = fmt.Fprintf(out, " table: %s\n", spec["table"])
_, _ = fmt.Fprintf(out, " category: %s\n", spec["category"])
_, _ = fmt.Fprintf(out, " intent: %s\n", spec["intent"])
p.Section("Synthesized ingest spec")
p.Field("table", fmt.Sprintf("%v", spec["table"]))
p.Field("category", fmt.Sprintf("%v", spec["category"]))
p.Field("intent", fmt.Sprintf("%v", spec["intent"]))
switch lbl := spec["label"].(type) {
case string:
_, _ = fmt.Fprintf(out, " label column: %s\n", lbl)
p.Field("label column", lbl)
case map[string]any:
_, _ = fmt.Fprintf(out, " label column: %v (policy: %v)\n", lbl["column"], lbl["policy"])
p.Field("label column", fmt.Sprintf("%v (policy: %v)", lbl["column"], lbl["policy"]))
}
if tc, ok := spec["time_column"].(string); ok && tc != "" {
_, _ = fmt.Fprintf(out, " time column: %s\n", tc)
p.Field("time column", tc)
}
_, _ = fmt.Fprintf(out, " destination: %s\n", push.FinalDestPrefix(spec["table"].(string)))
_, _ = fmt.Fprintln(out)
p.Field("destination", push.FinalDestPrefix(spec["table"].(string)))

if !dryRun {
_, _ = fmt.Fprintf(out, "Next: stage %d files (%s) for table %q\n",
p.Infof("Next: stage %d files (%s) for table %q",
layout.FileCount(), push.HumanBytes(layout.TotalBytes), spec["table"])
_, _ = fmt.Fprintln(out)
}
}
20 changes: 20 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ package cli

import (
"github.com/spf13/cobra"

"github.com/tracebloc/cli/internal/ui"
)

// BuildInfo carries metadata that main.go pulls from -ldflags. We
Expand Down Expand Up @@ -65,6 +67,13 @@ what's planned next.`,
SilenceUsage: true,
}

// Persistent flags apply to the root and every subcommand. --plain
// disables color + decorative output; ui.New also auto-plains on a
// non-TTY and honors $NO_COLOR, so this is the explicit opt-out for
// CI / log capture where stdout might still look like a terminal.
root.PersistentFlags().Bool("plain", false,
"disable color and decorative output (also honors $NO_COLOR)")

// Subcommands. New phases append here.
root.AddCommand(newVersionCmd(info))
root.AddCommand(newIngestCmd())
Expand All @@ -73,3 +82,14 @@ what's planned next.`,

return root
}

// printerFor builds a ui.Printer for a command's stdout, honoring the
// persistent --plain flag. Color / TTY / NO_COLOR auto-detection lives
// in ui.New; --plain just forces it off. Commands call this at the top
// of their RunE.
func printerFor(cmd *cobra.Command) *ui.Printer {
if plain, _ := cmd.Flags().GetBool("plain"); plain {
return ui.New(cmd.OutOrStdout(), ui.WithColor(false))
}
return ui.New(cmd.OutOrStdout())
}
Loading
Loading