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
16 changes: 14 additions & 2 deletions .github/workflows/build.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
106 changes: 106 additions & 0 deletions internal/cli/coverage_test.go
Original file line numberDiff line numberDiff line change
@@ -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())
}
}
44 changes: 44 additions & 0 deletions internal/push/progress_test.go
Original file line numberDiff line numberDiff line change
@@ -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)")
}
}
33 changes: 33 additions & 0 deletions internal/submit/watcherr_test.go
Original file line numberDiff line numberDiff line change
@@ -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")
}
}
12 changes: 12 additions & 0 deletions testdata/smoke/invalid-missing-images.yaml
Original file line numberDiff line numberDiff line change
@@ -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
12 changes: 12 additions & 0 deletions testdata/smoke/valid-image-classification.yaml
Original file line numberDiff line numberDiff line change
@@ -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
Loading