diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 21748368..0df784c9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -156,8 +156,20 @@ jobs: # validation only — which is still meaningful because # client-go and most k8s deps have OS-conditional code paths. run: | - ./dist/tracebloc-linux-amd64 version - ./dist/tracebloc-linux-amd64 version --output-json | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['version'], 'empty version'" + BIN=./dist/tracebloc-linux-amd64 + "$BIN" version + "$BIN" version --output-json | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['version'], 'empty version'" + + # ingest validate exercises the embedded schema + validation + # wiring on the real binary (no cluster needed): a valid spec + # must pass, and an invalid one must be rejected with exit 2. + "$BIN" ingest validate testdata/smoke/valid-image-classification.yaml + if "$BIN" ingest validate testdata/smoke/invalid-missing-images.yaml; then + echo "::error::ingest validate accepted an invalid spec (missing 'images')"; exit 1 + fi + + # Command-tree wiring smoke for the dominant verb. + "$BIN" dataset push --help >/dev/null - name: Upload binary as artifact uses: actions/upload-artifact@v4 diff --git a/internal/cli/coverage_test.go b/internal/cli/coverage_test.go new file mode 100644 index 00000000..0cbe4740 --- /dev/null +++ b/internal/cli/coverage_test.go @@ -0,0 +1,106 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + + "github.com/tracebloc/cli/internal/cluster" + "github.com/tracebloc/cli/internal/push" +) + +// TestPrintPushPreflight_RendersKeyFacts pins that the pre-flight +// summary surfaces the facts a customer sanity-checks before a push: +// the target release, the shared PVC, and the synthesized spec +// identity. It's the customer's last look before bytes move, so the +// content (not just "it didn't panic") is worth asserting. +func TestPrintPushPreflight_RendersKeyFacts(t *testing.T) { + layout := &push.LocalLayout{ + Root: "/tmp/cats_dogs", + LabelsCSV: "/tmp/cats_dogs/labels.csv", + Images: []string{"a.jpg", "b.jpg", "c.jpg"}, + TotalBytes: 1024, + } + release := &cluster.ParentRelease{ + ReleaseName: "ingdemo", + ChartVersion: "1.4.2", + JobsManagerService: "http://jobs-manager.ingdemo.svc.cluster.local:8080", + } + pvc := &cluster.SharedPVC{ + ClaimName: "client-pvc", + MountPath: "/data/shared", + Phase: corev1.ClaimBound, + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}, + } + spec := map[string]any{ + "table": "cats_dogs_train", + "category": "image_classification", + "intent": "train", + "label": "label", + } + + var buf bytes.Buffer + printPushPreflight(&buf, layout, release, pvc, spec, false) + out := buf.String() + + for _, want := range []string{ + "ingdemo", "1.4.2", "client-pvc", + "cats_dogs_train", "image_classification", "train", + } { + if !strings.Contains(out, want) { + t.Errorf("pre-flight output missing %q:\n%s", want, out) + } + } +} + +// TestExitError_Methods pins the exit-code carrier: Error() surfaces +// the wrapped message (or a fallback when nil), and Code() returns the +// process exit code main() propagates. +func TestExitError_Methods(t *testing.T) { + e := &exitError{code: 7, err: errors.New("staging failed")} + if e.Error() != "staging failed" { + t.Errorf("Error() = %q, want %q", e.Error(), "staging failed") + } + if e.Code() != 7 { + t.Errorf("Code() = %d, want 7", e.Code()) + } + // err==nil: Error() falls back to a generic "exit N" string so the + // type still satisfies error without panicking. + nilErr := &exitError{code: 2} + if !strings.Contains(nilErr.Error(), "2") { + t.Errorf("Error() on nil-err exitError = %q, want it to mention the code", nilErr.Error()) + } + if nilErr.Code() != 2 { + t.Errorf("Code() = %d, want 2", nilErr.Code()) + } +} + +// TestRunClusterInfo_BadKubeconfigExitsThree: an unreadable/invalid +// kubeconfig is exit-code-3 territory (the kubeconfig/local-input +// bucket), surfaced before any cluster work. Covers the Load-error +// branch of runClusterInfo without needing a real cluster. +func TestRunClusterInfo_BadKubeconfigExitsThree(t *testing.T) { + bad := filepath.Join(t.TempDir(), "broken.yaml") + if err := os.WriteFile(bad, []byte("}{ this is not valid kubeconfig"), 0o644); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + err := runClusterInfo(context.Background(), &buf, bad, "", "", "", 600) + if err == nil { + t.Fatal("runClusterInfo with a broken kubeconfig returned nil; want an exitError") + } + var ee *exitError + if !errors.As(err, &ee) { + t.Fatalf("error is not an *exitError: %v", err) + } + if ee.Code() != 3 { + t.Errorf("exit code = %d, want 3 (kubeconfig/local-input error)", ee.Code()) + } +} diff --git a/internal/push/progress_test.go b/internal/push/progress_test.go new file mode 100644 index 00000000..425e2107 --- /dev/null +++ b/internal/push/progress_test.go @@ -0,0 +1,44 @@ +package push + +import ( + "bytes" + "os" + "testing" +) + +// TestNewProgress_NonTTYReturnsNoOp: a non-terminal writer (a buffer, +// a redirected file, a CI log) must get the no-op sink — an animated +// bar in non-interactive output is noise. Covers NewProgress's +// dominant branch + NoOpProgress's methods. +func TestNewProgress_NonTTYReturnsNoOp(t *testing.T) { + var buf bytes.Buffer + p := NewProgress(&buf, 1000, "Staging x") + if _, ok := p.(NoOpProgress); !ok { + t.Fatalf("NewProgress(non-TTY) = %T, want NoOpProgress", p) + } + // Must be safe no-ops that write nothing. + p.Add(500) + p.Finish() + if buf.Len() != 0 { + t.Errorf("NoOpProgress wrote %d bytes to a non-TTY; want 0", buf.Len()) + } +} + +// TestIsTTY_False: neither a bytes.Buffer nor a pipe (a non-terminal +// *os.File) is a TTY. Pins the conservative detection that keeps CI +// output clean. +func TestIsTTY_False(t *testing.T) { + var buf bytes.Buffer + if isTTY(&buf) { + t.Error("isTTY(*bytes.Buffer) = true, want false") + } + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer func() { _ = r.Close() }() + defer func() { _ = w.Close() }() + if isTTY(w) { + t.Error("isTTY(os.Pipe writer) = true, want false (not a terminal)") + } +} diff --git a/internal/submit/watcherr_test.go b/internal/submit/watcherr_test.go new file mode 100644 index 00000000..4ddad1c9 --- /dev/null +++ b/internal/submit/watcherr_test.go @@ -0,0 +1,33 @@ +package submit + +import ( + "errors" + "fmt" + "testing" +) + +// TestWatchError_UnwrapAndClassify pins WatchError's wrapping: it +// reports the inner message, unwraps to the cause (so errors.Is works +// through it), and IsWatchError recognizes it while rejecting other +// errors. This drives the orchestrator's exit-9 (ingest-side) vs +// exit-8 (submit-side) mapping, so the classification must be exact. +func TestWatchError_UnwrapAndClassify(t *testing.T) { + inner := errors.New("pod log stream broke") + we := &WatchError{Err: inner} + + if we.Error() != inner.Error() { + t.Errorf("Error() = %q, want %q", we.Error(), inner.Error()) + } + // errors.Is traverses Unwrap — covers WatchError.Unwrap. + if !errors.Is(we, inner) { + t.Error("errors.Is(WatchError, inner) = false; Unwrap not wired") + } + // And through an extra wrap layer. + wrapped := fmt.Errorf("watching ingestor Job: %w", we) + if !IsWatchError(wrapped) { + t.Error("IsWatchError(wrapped WatchError) = false, want true") + } + if IsWatchError(errors.New("unrelated")) { + t.Error("IsWatchError(plain error) = true, want false") + } +} diff --git a/testdata/smoke/invalid-missing-images.yaml b/testdata/smoke/invalid-missing-images.yaml new file mode 100644 index 00000000..75706c65 --- /dev/null +++ b/testdata/smoke/invalid-missing-images.yaml @@ -0,0 +1,12 @@ +# Smoke-test fixture: an image_classification spec missing the +# required `images` directory. `tracebloc ingest validate` must REJECT +# it with exit 2 (schema violation). Guards against a regression where +# the embedded schema or the validator silently stops enforcing +# per-category requirements. +apiVersion: tracebloc.io/v1 +kind: IngestConfig +category: image_classification +table: smoke_test +intent: train +csv: /data/shared/smoke_test/labels.csv +label: label diff --git a/testdata/smoke/valid-image-classification.yaml b/testdata/smoke/valid-image-classification.yaml new file mode 100644 index 00000000..ac803c76 --- /dev/null +++ b/testdata/smoke/valid-image-classification.yaml @@ -0,0 +1,12 @@ +# Smoke-test fixture: a minimal, valid image_classification ingest +# spec. `tracebloc ingest validate` must accept it (exit 0). Used by +# the CI smoke step to exercise the embedded schema + validation wiring +# on the real built binary — no cluster required. +apiVersion: tracebloc.io/v1 +kind: IngestConfig +category: image_classification +table: smoke_test +intent: train +csv: /data/shared/smoke_test/labels.csv +images: /data/shared/smoke_test/images/ +label: label