diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..372c21d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: ci + +on: + push: + branches: [dev] + pull_request: + branches: [dev] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + GO_VERSION: "1.25.x" + GOLANGCI_VERSION: "v2.12.2" + +jobs: + build-test-lint: + name: build · vet · gofmt · lint · test · e2e + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 + with: + persist-credentials: false + + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: ${{ env.GO_VERSION }} + check-latest: true + cache: true + cache-dependency-path: go.sum + + - name: Download and verify modules + run: | + go mod download + go mod verify + + - name: gofmt check + run: | + diff=$(gofmt -l .) + if [ -n "$diff" ]; then + echo "::error::gofmt needed on:" + echo "$diff" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: golangci-lint + uses: golangci/golangci-lint-action@d583c34f0599d37dbac4a198b9c83201be380893 # v9.3.0 + with: + version: ${{ env.GOLANGCI_VERSION }} + args: ./... + + - name: Format and imports check + run: golangci-lint fmt --diff ./... + + - name: Build + run: go build -trimpath ./... + + - name: Unit tests with race detector + run: go test -race -count=1 -coverprofile=coverage.out ./... + + - name: Binary integration smoke test + run: go test -race -count=1 -tags=e2e ./tests/e2e diff --git a/.gitignore b/.gitignore index cbe3512..6e5ea02 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ bin/ vendor/ *.test *.out +coverage.* # Node / UI node_modules/ dist/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..1bf0774 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,43 @@ +version: "2" + +run: + timeout: 5m + tests: true + +linters: + default: none + enable: + - bodyclose + - errcheck + - gosec + - govet + - ineffassign + - misspell + - revive + - staticcheck + - unconvert + - unused + settings: + revive: + rules: + - name: exported + - name: package-comments + - name: error-strings + - name: context-as-argument + - name: unreachable-code + misspell: + locale: US + exclusions: + generated: lax + rules: + - path: _test\.go + linters: [errcheck, gosec] + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/ArdurAI/sith diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f36024d --- /dev/null +++ b/Makefile @@ -0,0 +1,60 @@ +# Sith — Makefile +SHELL := /usr/bin/env bash + +BINARY := sith +PKG := github.com/ArdurAI/sith +CMD := ./cmd/sith +BIN_DIR := bin +GOLANGCI ?= golangci-lint + +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) +DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) + +LDFLAGS := -s -w \ + -X $(PKG)/internal/buildinfo.Version=$(VERSION) \ + -X $(PKG)/internal/buildinfo.Commit=$(COMMIT) \ + -X $(PKG)/internal/buildinfo.Date=$(DATE) + +.PHONY: all build test e2e lint fmt fmt-check vet tidy clean run ci help + +all: build + +build: ## Build the sith binary into bin/ + @mkdir -p $(BIN_DIR) + go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN_DIR)/$(BINARY) $(CMD) + +test: ## Run unit tests with the race detector and report coverage + go test -race -count=1 -coverprofile=coverage.out ./... + +e2e: ## Build and exercise the real binary as a subprocess + go test -race -count=1 -tags=e2e ./tests/e2e + +lint: ## Run golangci-lint (v2) + $(GOLANGCI) run ./... + +fmt: ## Format code (gofmt + goimports via golangci-lint v2 formatters) + $(GOLANGCI) fmt ./... + +fmt-check: ## Fail if formatting/imports would change anything + gofmt -l . | tee /dev/stderr | (! read) + $(GOLANGCI) fmt --diff ./... + +vet: ## Run go vet + go vet ./... + +tidy: ## Tidy and verify modules + go mod tidy + go mod verify + +clean: ## Remove build and coverage artifacts + rm -rf $(BIN_DIR) coverage.out + +run: build ## Build then run sith version + $(BIN_DIR)/$(BINARY) version + +ci: fmt-check vet lint test e2e build ## Run the full CI gate locally + +help: ## List targets + @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \ + awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-10s\033[0m %s\n",$$1,$$2}' diff --git a/README.md b/README.md index b6636e8..1280e1e 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,30 @@ # Sith -**Status: planning.** No product code yet. +**Status: Slice 0 foundation.** The local-first CLI walking skeleton is runnable; Kubernetes +context discovery arrives in Slice 1. -Sith is a governed, multi-tenant control plane for **cross-cluster Kubernetes fleet -operations** — one place to safely *see* and *act* across many clusters, while deep -cluster access stays local. It is built on [Open Cluster Management (OCM)](https://open-cluster-management.io/). +Sith is ArdurAI's single-binary, local-first Kubernetes fleet tool: **k9s for your whole fleet**. +It is designed to aggregate every kubeconfig context without an account, telemetry, or cluster +data leaving the machine. The same source-abstract fleet model will later power an optional +governed hub. -The full charter, architecture, ADRs, threat model, roadmap, and competitive analysis -land via the initial planning pull request. The owner reviews the plan before any -implementation begins. +## Build and run -See the open PR and `docs/` for the plan. +Sith requires a supported Go 1.25 toolchain. + +```bash +make build +./bin/sith version +./bin/sith version --output json +./bin/sith clusters +``` + +Slice 0 intentionally returns a typed empty fleet through the stubbed `fleet.Source` seam. Run the +full local quality gate with a pinned golangci-lint v2.12.2 on `PATH`: + +```bash +make ci +``` + +The architecture, threat model, ADRs, and roadmap live under [`docs/`](docs/). Build-session +checkpoints are recorded under [`sessions/`](sessions/). diff --git a/cmd/sith/main.go b/cmd/sith/main.go new file mode 100644 index 0000000..7dd7414 --- /dev/null +++ b/cmd/sith/main.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package main is the process entrypoint for the Sith binary. +package main + +import ( + "os" + + "github.com/ArdurAI/sith/internal/cli" +) + +func main() { + os.Exit(cli.Execute()) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..da62d11 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/ArdurAI/sith + +go 1.25.0 + +require ( + github.com/spf13/cobra v1.10.2 + go.yaml.in/yaml/v3 v3.0.4 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e63b363 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..7fc0384 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package buildinfo exposes build metadata injected by the build pipeline. +package buildinfo + +import ( + "encoding/json" + "fmt" + "runtime" +) + +// Version is the semantic version or development identifier injected at build time. +var Version = "dev" + +// Commit is the source commit injected at build time. +var Commit = "none" + +// Date is the UTC build timestamp injected at build time. +var Date = "unknown" + +// Info is the resolved build metadata, including runtime-derived fields. +type Info struct { + Version string `json:"version"` + Commit string `json:"commit"` + Date string `json:"date"` + Go string `json:"go"` + Platform string `json:"platform"` +} + +// Get returns the build metadata combined with the active Go runtime and platform. +func Get() Info { + return Info{ + Version: Version, + Commit: Commit, + Date: Date, + Go: runtime.Version(), + Platform: runtime.GOOS + "/" + runtime.GOARCH, + } +} + +// String returns build metadata in the human-readable CLI format. +func (i Info) String() string { + return fmt.Sprintf( + "sith %s\n commit: %s\n built: %s\n go: %s\n platform: %s", + i.Version, + i.Commit, + i.Date, + i.Go, + i.Platform, + ) +} + +// JSON returns build metadata as compact JSON. +func (i Info) JSON() (string, error) { + data, err := json.Marshal(i) + if err != nil { + return "", fmt.Errorf("marshal build metadata: %w", err) + } + + return string(data), nil +} diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go new file mode 100644 index 0000000..1b85fba --- /dev/null +++ b/internal/buildinfo/buildinfo_test.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 + +package buildinfo + +import ( + "encoding/json" + "runtime" + "strings" + "testing" +) + +func TestGetPopulatesRuntimeFields(t *testing.T) { + t.Parallel() + + got := Get() + if got.Go != runtime.Version() { + t.Fatalf("Go = %q, want %q", got.Go, runtime.Version()) + } + + wantPlatform := runtime.GOOS + "/" + runtime.GOARCH + if got.Platform != wantPlatform { + t.Fatalf("Platform = %q, want %q", got.Platform, wantPlatform) + } +} + +func TestStringContainsAllFields(t *testing.T) { + t.Parallel() + + info := Info{ + Version: "v1.2.3", + Commit: "abc1234", + Date: "2026-07-10T12:00:00Z", + Go: "go1.25.12", + Platform: "linux/amd64", + } + + for _, want := range []string{info.Version, info.Commit, info.Date, info.Go, info.Platform} { + if !strings.Contains(info.String(), want) { + t.Errorf("String() = %q, want it to contain %q", info.String(), want) + } + } +} + +func TestJSONRoundTrips(t *testing.T) { + t.Parallel() + + want := Info{ + Version: "v1.2.3", + Commit: "abc1234", + Date: "2026-07-10T12:00:00Z", + Go: "go1.25.12", + Platform: "linux/amd64", + } + + encoded, err := want.JSON() + if err != nil { + t.Fatalf("JSON() error = %v", err) + } + + var got Info + if err := json.Unmarshal([]byte(encoded), &got); err != nil { + t.Fatalf("unmarshal JSON: %v", err) + } + + if got != want { + t.Fatalf("round trip = %#v, want %#v", got, want) + } +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..e6d13e0 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "runtime" + "strings" + "testing" + "time" + + "github.com/ArdurAI/sith/internal/fleet" +) + +type staticSource struct { + result fleet.FleetResult + called bool +} + +type failingSource struct{} + +func (failingSource) Kind() string { + return "failing" +} + +func (failingSource) Fleet(_ context.Context) (fleet.FleetResult, error) { + return fleet.FleetResult{}, errors.New("source unavailable") +} + +func (*staticSource) Kind() string { + return "memory" +} + +func (source *staticSource) Fleet(_ context.Context) (fleet.FleetResult, error) { + source.called = true + return source.result, nil +} + +func TestVersionText(t *testing.T) { + stdout, _, exitCode := runCLI(t, []string{"version"}, fleet.StubSource{}) + if exitCode != 0 { + t.Fatalf("exit code = %d, want 0", exitCode) + } + if !strings.Contains(stdout, "sith ") || !strings.Contains(stdout, runtime.GOOS+"/"+runtime.GOARCH) { + t.Fatalf("stdout = %q, want version and platform", stdout) + } +} + +func TestVersionJSON(t *testing.T) { + stdout, _, exitCode := runCLI(t, []string{"version", "-o", "json"}, fleet.StubSource{}) + if exitCode != 0 { + t.Fatalf("exit code = %d, want 0", exitCode) + } + + var got map[string]any + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("unmarshal stdout %q: %v", stdout, err) + } + for _, key := range []string{"version", "commit", "date", "go", "platform"} { + if _, ok := got[key]; !ok { + t.Errorf("JSON missing key %q: %#v", key, got) + } + } +} + +func TestClustersEmptyText(t *testing.T) { + stdout, _, exitCode := runCLI(t, []string{"clusters"}, fleet.StubSource{}) + if exitCode != 0 { + t.Fatalf("exit code = %d, want 0", exitCode) + } + const want = "No clusters found (source: stub — F2.1/#38 not yet implemented).\n" + if stdout != want { + t.Fatalf("stdout = %q, want %q", stdout, want) + } +} + +func TestClustersEmptyJSON(t *testing.T) { + stdout, _, exitCode := runCLI(t, []string{"clusters", "-o", "json"}, fleet.StubSource{}) + if exitCode != 0 { + t.Fatalf("exit code = %d, want 0", exitCode) + } + + var got fleet.FleetResult + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("unmarshal stdout %q: %v", stdout, err) + } + if got.Clusters == nil || len(got.Clusters) != 0 { + t.Fatalf("clusters = %#v, want allocated empty slice", got.Clusters) + } +} + +func TestClustersEmptyNonStubNamesSource(t *testing.T) { + source := &staticSource{result: fleet.FleetResult{}} + + stdout, _, exitCode := runCLI(t, []string{"clusters"}, source) + if exitCode != 0 { + t.Fatalf("exit code = %d, want 0", exitCode) + } + if stdout != "No clusters found (source: memory).\n" { + t.Fatalf("stdout = %q, want dynamic source name", stdout) + } +} + +func TestClustersNormalizesNilSliceInJSON(t *testing.T) { + source := &staticSource{result: fleet.FleetResult{}} + + stdout, _, exitCode := runCLI(t, []string{"clusters", "-o", "json"}, source) + if exitCode != 0 { + t.Fatalf("exit code = %d, want 0", exitCode) + } + const want = `{"clusters":[],"coverage":{"requested":0,"reachable":0}}` + "\n" + if stdout != want { + t.Fatalf("stdout = %q, want %q", stdout, want) + } +} + +func TestClustersSourceErrorIsConcise(t *testing.T) { + _, stderr, exitCode := runCLI(t, []string{"clusters"}, failingSource{}) + if exitCode == 0 { + t.Fatal("exit code = 0, want non-zero") + } + if !strings.Contains(stderr, "source unavailable") { + t.Fatalf("stderr = %q, want source error", stderr) + } + if strings.Contains(stderr, "Usage:") { + t.Fatalf("stderr = %q, want no usage noise", stderr) + } +} + +func TestClustersUsesInjectedSource(t *testing.T) { + source := &staticSource{result: fleet.FleetResult{ + Clusters: []fleet.Cluster{ + { + Name: "prod-us", + Context: "prod-us-admin", + SourceKind: "memory", + Reachable: true, + ObservedAt: time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC), + }, + {Name: "lab", Context: "lab", SourceKind: "memory"}, + }, + }} + + stdout, _, exitCode := runCLI(t, []string{"clusters"}, source) + if exitCode != 0 { + t.Fatalf("exit code = %d, want 0", exitCode) + } + if !source.called { + t.Fatal("injected source was not called") + } + for _, want := range []string{"NAME", "prod-us", "prod-us-admin", "lab", "memory"} { + if !strings.Contains(stdout, want) { + t.Errorf("stdout = %q, want it to contain %q", stdout, want) + } + } +} + +func TestUIStub(t *testing.T) { + stdout, _, exitCode := runCLI(t, []string{"ui"}, fleet.StubSource{}) + if exitCode != 0 || stdout != "sith ui: not yet implemented — see F11.3 (#34).\n" { + t.Fatalf("exit/stdout = %d/%q", exitCode, stdout) + } +} + +func TestHubStub(t *testing.T) { + stdout, _, exitCode := runCLI(t, []string{"hub"}, fleet.StubSource{}) + if exitCode != 0 || stdout != "sith hub: not yet implemented — hub mode is phase-1+ (E1–E10).\n" { + t.Fatalf("exit/stdout = %d/%q", exitCode, stdout) + } +} + +func TestRootHelpExitsZero(t *testing.T) { + stdout, stderr, exitCode := runCLI(t, []string{"--help"}, fleet.StubSource{}) + if exitCode != 0 { + t.Fatalf("exit code = %d, want 0; stderr = %q", exitCode, stderr) + } + if !strings.Contains(stdout, "Usage:") || !strings.Contains(stdout, "sith [flags]") { + t.Fatalf("stdout = %q, want usage", stdout) + } +} + +func TestRootNoArgsExitsZero(t *testing.T) { + stdout, stderr, exitCode := runCLI(t, nil, fleet.StubSource{}) + if exitCode != 0 { + t.Fatalf("exit code = %d, want 0; stderr = %q", exitCode, stderr) + } + if !strings.Contains(stdout, "Usage:") { + t.Fatalf("stdout = %q, want usage", stdout) + } +} + +func TestUnknownCommandNonZero(t *testing.T) { + _, stderr, exitCode := runCLI(t, []string{"bogus"}, fleet.StubSource{}) + if exitCode == 0 { + t.Fatal("exit code = 0, want non-zero") + } + if !strings.Contains(stderr, "unknown command") { + t.Fatalf("stderr = %q, want unknown command error", stderr) + } +} + +func TestInvalidLogLevelFlagFails(t *testing.T) { + _, stderr, exitCode := runCLI(t, []string{"clusters", "--log-level", "nope"}, fleet.StubSource{}) + if exitCode == 0 { + t.Fatal("exit code = 0, want non-zero") + } + if !strings.Contains(stderr, "invalid log level") { + t.Fatalf("stderr = %q, want invalid log level error", stderr) + } +} + +func runCLI(t *testing.T, args []string, source fleet.Source) (stdout, stderr string, exitCode int) { + t.Helper() + + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("SITH_LOG_LEVEL", "") + t.Setenv("SITH_LOG_FORMAT", "") + t.Setenv("SITH_KUBECONFIG", "") + + var stdoutBuffer bytes.Buffer + var stderrBuffer bytes.Buffer + exitCode = execute(args, source, &stdoutBuffer, &stderrBuffer) + return stdoutBuffer.String(), stderrBuffer.String(), exitCode +} diff --git a/internal/cli/clusters.go b/internal/cli/clusters.go new file mode 100644 index 0000000..9e7e194 --- /dev/null +++ b/internal/cli/clusters.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/ArdurAI/sith/internal/fleet" +) + +func newClustersCommand(options *rootOptions, source fleet.Source) *cobra.Command { + return &cobra.Command{ + Use: "clusters", + Short: "List clusters from the configured fleet source", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if source == nil { + return fmt.Errorf("fleet source is not configured") + } + + result, err := source.Fleet(command.Context()) + if err != nil { + return fmt.Errorf("read fleet from %s source: %w", source.Kind(), err) + } + if result.Clusters == nil { + result.Clusters = []fleet.Cluster{} + } + + if options.output == "json" { + if err := json.NewEncoder(command.OutOrStdout()).Encode(result); err != nil { + return fmt.Errorf("write clusters JSON: %w", err) + } + return nil + } + + if len(result.Clusters) == 0 { + message := fmt.Sprintf("No clusters found (source: %s).", source.Kind()) + if source.Kind() == "stub" { + message = "No clusters found (source: stub — F2.1/#38 not yet implemented)." + } + if _, err := fmt.Fprintln(command.OutOrStdout(), message); err != nil { + return fmt.Errorf("write empty clusters result: %w", err) + } + return nil + } + + return writeClusterTable(command.OutOrStdout(), result.Clusters) + }, + } +} + +func writeClusterTable(output io.Writer, clusters []fleet.Cluster) error { + var rendered bytes.Buffer + table := tabwriter.NewWriter(&rendered, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(table, "NAME\tCONTEXT\tSOURCE\tREACHABLE\tOBSERVED"); err != nil { + return fmt.Errorf("write cluster table header: %w", err) + } + + for _, cluster := range clusters { + contextName := cluster.Context + if contextName == "" { + contextName = "-" + } + observed := "-" + if !cluster.ObservedAt.IsZero() { + observed = cluster.ObservedAt.UTC().Format(time.RFC3339) + } + if _, err := fmt.Fprintf( + table, + "%s\t%s\t%s\t%t\t%s\n", + cluster.Name, + contextName, + cluster.SourceKind, + cluster.Reachable, + observed, + ); err != nil { + return fmt.Errorf("write cluster table row: %w", err) + } + } + + if err := table.Flush(); err != nil { + return fmt.Errorf("flush cluster table: %w", err) + } + if _, err := io.Copy(output, &rendered); err != nil { + return fmt.Errorf("write cluster table: %w", err) + } + + return nil +} diff --git a/internal/cli/hub.go b/internal/cli/hub.go new file mode 100644 index 0000000..eb0c2fa --- /dev/null +++ b/internal/cli/hub.go @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newHubCommand() *cobra.Command { + return &cobra.Command{ + Use: "hub", + Short: "Start the governed fleet hub", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if _, err := fmt.Fprintln(command.OutOrStdout(), "sith hub: not yet implemented — hub mode is phase-1+ (E1–E10)."); err != nil { + return fmt.Errorf("write hub status: %w", err) + } + return nil + }, + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..bea3098 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package cli wires the Sith command tree to source-abstract domain packages. +package cli + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + + "github.com/spf13/cobra" + + "github.com/ArdurAI/sith/internal/config" + "github.com/ArdurAI/sith/internal/fleet" + "github.com/ArdurAI/sith/internal/logging" +) + +type runtimeKey struct{} + +type runtimeState struct { + config config.Config + logger *slog.Logger +} + +type rootOptions struct { + configPath string + logLevel string + logFormat string + output string +} + +// Execute builds and runs the command tree, returning a process exit code. +func Execute() int { + return execute(os.Args[1:], fleet.StubSource{}, os.Stdout, os.Stderr) +} + +func execute(args []string, source fleet.Source, stdout, stderr io.Writer) int { + command := newRootCommand(source, stdout, stderr) + command.SetArgs(args) + if err := command.Execute(); err != nil { + if _, writeErr := fmt.Fprintln(stderr, err); writeErr != nil { + return 2 + } + return 1 + } + + return 0 +} + +func newRootCommand(source fleet.Source, stdout, stderr io.Writer) *cobra.Command { + options := &rootOptions{output: "text"} + command := &cobra.Command{ + Use: "sith", + Short: "ArdurAI's local-first Kubernetes fleet client", + Long: "Sith is ArdurAI's local-first client for source-abstract, cross-cluster Kubernetes fleet operations.", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(command *cobra.Command, _ []string) error { + return command.Help() + }, + PersistentPreRunE: func(command *cobra.Command, _ []string) error { + if options.output != "text" && options.output != "json" { + return fmt.Errorf("invalid output format %q: expected text or json", options.output) + } + + resolved, err := config.Load(options.configPath, config.Overrides{ + LogLevel: options.logLevel, + LogFormat: options.logFormat, + }) + if err != nil { + return fmt.Errorf("load configuration: %w", err) + } + + logger, err := logging.New(stderr, resolved.LogLevel, resolved.LogFormat) + if err != nil { + return fmt.Errorf("configure logging: %w", err) + } + + state := runtimeState{config: resolved, logger: logger} + command.SetContext(context.WithValue(command.Context(), runtimeKey{}, state)) + return nil + }, + } + command.SetOut(stdout) + command.SetErr(stderr) + command.CompletionOptions.DisableDefaultCmd = true + + flags := command.PersistentFlags() + flags.StringVar(&options.configPath, "config", "", "path to the YAML configuration file") + flags.StringVar(&options.logLevel, "log-level", "", "logging level: debug, info, warn, or error (default info)") + flags.StringVar(&options.logFormat, "log-format", "", "logging format: text or json (default text)") + flags.StringVarP(&options.output, "output", "o", "text", "output format: text or json") + + command.AddCommand( + newVersionCommand(options), + newClustersCommand(options, source), + newUICommand(), + newHubCommand(), + ) + + return command +} diff --git a/internal/cli/ui.go b/internal/cli/ui.go new file mode 100644 index 0000000..6cecd34 --- /dev/null +++ b/internal/cli/ui.go @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newUICommand() *cobra.Command { + return &cobra.Command{ + Use: "ui", + Short: "Start the local fleet IDE", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if _, err := fmt.Fprintln(command.OutOrStdout(), "sith ui: not yet implemented — see F11.3 (#34)."); err != nil { + return fmt.Errorf("write ui status: %w", err) + } + return nil + }, + } +} diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..cc0a51f --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ArdurAI/sith/internal/buildinfo" +) + +func newVersionCommand(options *rootOptions) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print build information", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + info := buildinfo.Get() + if options.output == "json" { + encoded, err := info.JSON() + if err != nil { + return err + } + if _, err := fmt.Fprintln(command.OutOrStdout(), encoded); err != nil { + return fmt.Errorf("write version output: %w", err) + } + return nil + } + + if _, err := fmt.Fprintln(command.OutOrStdout(), info.String()); err != nil { + return fmt.Errorf("write version output: %w", err) + } + return nil + }, + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..717cbe0 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package config loads and validates local Sith configuration. +package config + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "go.yaml.in/yaml/v3" +) + +const maxConfigBytes = 1 << 20 + +// Config contains local-mode process settings. +type Config struct { + LogLevel string `yaml:"log_level"` + LogFormat string `yaml:"log_format"` + KubeconfigPath string `yaml:"kubeconfig_path"` +} + +// Overrides contains non-empty command-line values that take final precedence. +type Overrides struct { + LogLevel string + LogFormat string +} + +// Defaults returns the safe local-mode defaults. +func Defaults() Config { + return Config{ + LogLevel: "info", + LogFormat: "text", + } +} + +// Load resolves defaults, an optional YAML file, environment variables, and flag overrides. +func Load(path string, overrides Overrides) (Config, error) { + resolvedPath, explicit, err := resolvePath(path) + if err != nil { + return Config{}, err + } + + resolved := Defaults() + if err := mergeFile(resolvedPath, explicit, &resolved); err != nil { + return Config{}, err + } + + applyEnvironment(&resolved) + applyOverrides(overrides, &resolved) + + if err := resolved.Validate(); err != nil { + return Config{}, err + } + + return resolved, nil +} + +// Validate rejects unknown logging values rather than silently weakening behavior. +func (c Config) Validate() error { + switch c.LogLevel { + case "debug", "info", "warn", "error": + default: + return fmt.Errorf("invalid log level %q: expected debug, info, warn, or error", c.LogLevel) + } + + switch c.LogFormat { + case "text", "json": + default: + return fmt.Errorf("invalid log format %q: expected text or json", c.LogFormat) + } + + return nil +} + +func resolvePath(path string) (resolved string, explicit bool, err error) { + if path != "" { + return path, true, nil + } + + if root := os.Getenv("XDG_CONFIG_HOME"); root != "" { + return filepath.Join(root, "sith", "config.yaml"), false, nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", false, fmt.Errorf("resolve home directory: %w", err) + } + + return filepath.Join(home, ".config", "sith", "config.yaml"), false, nil +} + +func mergeFile(path string, explicit bool, resolved *Config) error { + data, err := readConfig(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) && !explicit { + return nil + } + + return fmt.Errorf("read config %q: %w", path, err) + } + + if len(bytes.TrimSpace(data)) == 0 { + return nil + } + + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(resolved); err != nil { + return fmt.Errorf("decode config %q: %w", path, err) + } + + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return fmt.Errorf("decode config %q: multiple YAML documents are not supported", path) + } + + return fmt.Errorf("decode config %q: %w", path, err) + } + + return nil +} + +func readConfig(path string) ([]byte, error) { + // The path is explicitly selected by the local user or resolved under their config directory. + file, err := os.Open(path) //nolint:gosec // reading that user-selected path is the intended behavior + if err != nil { + return nil, err + } + defer func() { + _ = file.Close() + }() + + info, err := file.Stat() + if err != nil { + return nil, fmt.Errorf("stat: %w", err) + } + if info.Size() > maxConfigBytes { + return nil, fmt.Errorf("file is %d bytes, maximum is %d", info.Size(), maxConfigBytes) + } + + data, err := io.ReadAll(io.LimitReader(file, maxConfigBytes+1)) + if err != nil { + return nil, fmt.Errorf("read: %w", err) + } + if len(data) > maxConfigBytes { + return nil, fmt.Errorf("file exceeds maximum size of %d bytes", maxConfigBytes) + } + + return data, nil +} + +func applyEnvironment(resolved *Config) { + if value := os.Getenv("SITH_LOG_LEVEL"); value != "" { + resolved.LogLevel = value + } + if value := os.Getenv("SITH_LOG_FORMAT"); value != "" { + resolved.LogFormat = value + } + if value := os.Getenv("SITH_KUBECONFIG"); value != "" { + resolved.KubeconfigPath = value + } +} + +func applyOverrides(overrides Overrides, resolved *Config) { + if overrides.LogLevel != "" { + resolved.LogLevel = overrides.LogLevel + } + if overrides.LogFormat != "" { + resolved.LogFormat = overrides.LogFormat + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..3856fa3 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDefaults(t *testing.T) { + t.Parallel() + + want := Config{LogLevel: "info", LogFormat: "text"} + if got := Defaults(); got != want { + t.Fatalf("Defaults() = %#v, want %#v", got, want) + } +} + +func TestLoadFromFile(t *testing.T) { + clearConfigEnvironment(t) + path := writeConfig(t, "log_level: debug\nlog_format: json\n") + + got, err := Load(path, Overrides{}) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.LogLevel != "debug" || got.LogFormat != "json" { + t.Fatalf("Load() = %#v, want file values", got) + } +} + +func TestEnvOverridesFile(t *testing.T) { + clearConfigEnvironment(t) + path := writeConfig(t, "log_level: debug\nlog_format: json\n") + t.Setenv("SITH_LOG_LEVEL", "warn") + t.Setenv("SITH_LOG_FORMAT", "text") + + got, err := Load(path, Overrides{}) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.LogLevel != "warn" || got.LogFormat != "text" { + t.Fatalf("Load() = %#v, want environment values", got) + } +} + +func TestOverridesBeatEnv(t *testing.T) { + clearConfigEnvironment(t) + t.Setenv("SITH_LOG_LEVEL", "warn") + t.Setenv("SITH_LOG_FORMAT", "json") + + got, err := Load("", Overrides{LogLevel: "error", LogFormat: "text"}) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.LogLevel != "error" || got.LogFormat != "text" { + t.Fatalf("Load() = %#v, want flag overrides", got) + } +} + +func TestMissingDefaultFileIsOK(t *testing.T) { + clearConfigEnvironment(t) + + got, err := Load("", Overrides{}) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got != Defaults() { + t.Fatalf("Load() = %#v, want %#v", got, Defaults()) + } +} + +func TestExplicitMissingPathErrors(t *testing.T) { + clearConfigEnvironment(t) + + _, err := Load(filepath.Join(t.TempDir(), "missing.yaml"), Overrides{}) + if err == nil { + t.Fatal("Load() error = nil, want an error") + } +} + +func TestInvalidLevelRejected(t *testing.T) { + t.Parallel() + + for _, level := range []string{"", "verbose", "INFO"} { + level := level + t.Run(level, func(t *testing.T) { + t.Parallel() + if err := (Config{LogLevel: level, LogFormat: "text"}).Validate(); err == nil { + t.Fatalf("Validate() error = nil for log level %q", level) + } + }) + } +} + +func TestInvalidFormatRejected(t *testing.T) { + t.Parallel() + + for _, format := range []string{"", "console", "JSON"} { + format := format + t.Run(format, func(t *testing.T) { + t.Parallel() + if err := (Config{LogLevel: "info", LogFormat: format}).Validate(); err == nil { + t.Fatalf("Validate() error = nil for log format %q", format) + } + }) + } +} + +func TestUnknownFieldRejected(t *testing.T) { + clearConfigEnvironment(t) + path := writeConfig(t, "log_level: info\ntelemetry: true\n") + + if _, err := Load(path, Overrides{}); err == nil { + t.Fatal("Load() error = nil, want unknown field rejection") + } +} + +func TestMultipleDocumentsRejected(t *testing.T) { + clearConfigEnvironment(t) + path := writeConfig(t, "log_level: info\n---\nlog_format: json\n") + + if _, err := Load(path, Overrides{}); err == nil { + t.Fatal("Load() error = nil, want multiple document rejection") + } +} + +func TestOversizedFileRejected(t *testing.T) { + clearConfigEnvironment(t) + path := writeConfig(t, strings.Repeat("x", maxConfigBytes+1)) + + if _, err := Load(path, Overrides{}); err == nil { + t.Fatal("Load() error = nil, want oversized file rejection") + } +} + +func TestKubeconfigEnvironmentApplied(t *testing.T) { + clearConfigEnvironment(t) + t.Setenv("SITH_KUBECONFIG", "/tmp/fleet-kubeconfig") + + got, err := Load("", Overrides{}) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.KubeconfigPath != "/tmp/fleet-kubeconfig" { + t.Fatalf("KubeconfigPath = %q", got.KubeconfigPath) + } +} + +func clearConfigEnvironment(t *testing.T) { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("SITH_LOG_LEVEL", "") + t.Setenv("SITH_LOG_FORMAT", "") + t.Setenv("SITH_KUBECONFIG", "") +} + +func writeConfig(t *testing.T, contents string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + return path +} diff --git a/internal/fleet/fleet_test.go b/internal/fleet/fleet_test.go new file mode 100644 index 0000000..b5232c8 --- /dev/null +++ b/internal/fleet/fleet_test.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 + +package fleet + +import ( + "context" + "encoding/json" + "testing" +) + +var _ Source = StubSource{} +var _ Source = memorySource{} + +type memorySource struct { + result FleetResult +} + +func (memorySource) Kind() string { + return "memory" +} + +func (source memorySource) Fleet(_ context.Context) (FleetResult, error) { + return source.result, nil +} + +func TestStubSourceKind(t *testing.T) { + t.Parallel() + + if got := (StubSource{}).Kind(); got != "stub" { + t.Fatalf("Kind() = %q, want stub", got) + } +} + +func TestStubSourceEmpty(t *testing.T) { + t.Parallel() + + got, err := (StubSource{}).Fleet(context.Background()) + if err != nil { + t.Fatalf("Fleet() error = %v", err) + } + if got.Clusters == nil || len(got.Clusters) != 0 { + t.Fatalf("Clusters = %#v, want allocated empty slice", got.Clusters) + } + if got.Coverage.Requested != 0 || got.Coverage.Reachable != 0 || got.Coverage.Unreachable != nil { + t.Fatalf("Coverage = %#v, want zero value", got.Coverage) + } +} + +func TestSourceInterfaceSatisfied(t *testing.T) { + t.Parallel() + + sources := []Source{ + StubSource{}, + memorySource{result: FleetResult{Clusters: []Cluster{{Name: "lab"}}}}, + } + + for _, source := range sources { + if _, err := source.Fleet(context.Background()); err != nil { + t.Fatalf("%s Fleet() error = %v", source.Kind(), err) + } + } +} + +func TestFleetResultJSONShape(t *testing.T) { + t.Parallel() + + result := FleetResult{Clusters: []Cluster{}, Coverage: Coverage{}} + encoded, err := json.Marshal(result) + if err != nil { + t.Fatalf("marshal fleet result: %v", err) + } + + const want = `{"clusters":[],"coverage":{"requested":0,"reachable":0}}` + if string(encoded) != want { + t.Fatalf("JSON = %s, want %s", encoded, want) + } +} diff --git a/internal/fleet/model.go b/internal/fleet/model.go new file mode 100644 index 0000000..b1b9552 --- /dev/null +++ b/internal/fleet/model.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package fleet defines the source-abstract fleet snapshot consumed by Sith surfaces. +package fleet + +import "time" + +// FleetResult is the normalized snapshot returned by a Source. +// +//nolint:revive // FleetResult is the locked cross-slice contract name from issue #47. +type FleetResult struct { + Clusters []Cluster `json:"clusters"` + Coverage Coverage `json:"coverage"` +} + +// Cluster is one cluster or context in the fleet, stamped with source and freshness. +type Cluster struct { + Name string `json:"name"` + Context string `json:"context,omitempty"` + SourceKind string `json:"source_kind"` + Reachable bool `json:"reachable"` + ObservedAt time.Time `json:"observed_at,omitempty"` +} + +// Coverage summarizes which requested scopes answered and which were unreachable. +type Coverage struct { + Requested int `json:"requested"` + Reachable int `json:"reachable"` + Unreachable []string `json:"unreachable,omitempty"` +} diff --git a/internal/fleet/source.go b/internal/fleet/source.go new file mode 100644 index 0000000..e150ebd --- /dev/null +++ b/internal/fleet/source.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 + +package fleet + +import "context" + +// Source is the read seam implemented by every fleet snapshot backend. +type Source interface { + // Kind identifies the adapter, such as stub, local-kubeconfig, or ocm-spoke. + Kind() string + // Fleet returns the current normalized fleet snapshot for this source. + Fleet(ctx context.Context) (FleetResult, error) +} diff --git a/internal/fleet/stub.go b/internal/fleet/stub.go new file mode 100644 index 0000000..d6ccc70 --- /dev/null +++ b/internal/fleet/stub.go @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 + +package fleet + +import "context" + +var _ Source = StubSource{} + +// StubSource returns a well-formed empty fleet until Slice 1 adds kubeconfig discovery. +type StubSource struct{} + +// Kind identifies this source as the Slice 0 stub. +func (StubSource) Kind() string { + return "stub" +} + +// Fleet returns an allocated empty cluster slice and zero coverage. +func (StubSource) Fleet(_ context.Context) (FleetResult, error) { + return FleetResult{ + Clusters: []Cluster{}, + Coverage: Coverage{}, + }, nil +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..9ddd1f0 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package logging constructs structured loggers for Sith commands. +package logging + +import ( + "fmt" + "io" + "log/slog" +) + +// New builds a structured logger at the requested level and output format. +func New(w io.Writer, level, format string) (*slog.Logger, error) { + resolvedLevel, err := parseLevel(level) + if err != nil { + return nil, err + } + + options := &slog.HandlerOptions{Level: resolvedLevel} + var handler slog.Handler + switch format { + case "text": + handler = slog.NewTextHandler(w, options) + case "json": + handler = slog.NewJSONHandler(w, options) + default: + return nil, fmt.Errorf("invalid log format %q: expected text or json", format) + } + + return slog.New(handler), nil +} + +func parseLevel(level string) (slog.Level, error) { + switch level { + case "debug": + return slog.LevelDebug, nil + case "info": + return slog.LevelInfo, nil + case "warn": + return slog.LevelWarn, nil + case "error": + return slog.LevelError, nil + default: + return 0, fmt.Errorf("invalid log level %q: expected debug, info, warn, or error", level) + } +} diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go new file mode 100644 index 0000000..23aef9d --- /dev/null +++ b/internal/logging/logging_test.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 + +package logging + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestNewTextHandler(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + logger, err := New(&output, "info", "text") + if err != nil { + t.Fatalf("New() error = %v", err) + } + + logger.Info("ready", "clusters", 0) + if got := output.String(); !strings.Contains(got, "level=INFO") || !strings.Contains(got, "msg=ready") { + t.Fatalf("text log = %q, want level and message", got) + } + if strings.HasPrefix(strings.TrimSpace(output.String()), "{") { + t.Fatalf("text log unexpectedly looks like JSON: %q", output.String()) + } +} + +func TestNewJSONHandler(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + logger, err := New(&output, "info", "json") + if err != nil { + t.Fatalf("New() error = %v", err) + } + + logger.Info("ready", "clusters", 0) + var entry map[string]any + if err := json.Unmarshal(output.Bytes(), &entry); err != nil { + t.Fatalf("unmarshal log line %q: %v", output.String(), err) + } + if entry["msg"] != "ready" || entry["level"] != "INFO" { + t.Fatalf("JSON log = %#v, want ready/INFO", entry) + } +} + +func TestLevelFiltering(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + logger, err := New(&output, "warn", "text") + if err != nil { + t.Fatalf("New() error = %v", err) + } + + logger.Info("hidden") + logger.Warn("visible") + if strings.Contains(output.String(), "hidden") || !strings.Contains(output.String(), "visible") { + t.Fatalf("filtered output = %q", output.String()) + } +} + +func TestInvalidLevelOrFormatErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + level string + format string + }{ + {name: "level", level: "verbose", format: "text"}, + {name: "format", level: "info", format: "console"}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if _, err := New(&bytes.Buffer{}, test.level, test.format); err == nil { + t.Fatal("New() error = nil, want an error") + } + }) + } +} diff --git a/sessions/2026-07-10-slice-0-foundation.md b/sessions/2026-07-10-slice-0-foundation.md new file mode 100644 index 0000000..97d994d --- /dev/null +++ b/sessions/2026-07-10-slice-0-foundation.md @@ -0,0 +1,53 @@ +# Session — 2026-07-10 — slice-0-foundation + +**Builder:** Gnani Rahul · **Model/effort:** engineering, max · **Branch:** feat/slice-0-foundation +**Slice(s):** Slice 0 / #47 · **Status:** in-progress + +--- + +[G] Goal: Land the Slice 0 walking skeleton from issue #47 on `dev`: build metadata, config, +structured logging, the typed `fleet.Source` stub seam, the Cobra CLI, tests, and green CI. +[S] Scope: `cmd/sith`, `internal/{buildinfo,config,logging,fleet,cli}`, build/CI files, binary smoke +tests, README, and the GSTACK scaffold. Kubeconfig/client-go, TUI, web UI, MCP, keychain, and hub +implementation are explicitly out of scope. +[A] Action: Verified the canonical checkout at `/Volumes/EXTENDED/repos/sith`, read the locked specs +and issue #47, created `feat/slice-0-foundation` from `origin/dev`, and isolated the work in +`/Volumes/EXTENDED/repos/sith-slice-0`. +[A] Action: Replaced three stale spec pins with supported equivalents: Go 1.25 (supported +oldstable), the maintained `go.yaml.in/yaml/v3` fork, and current supported GitHub Actions plus +golangci-lint v2.12.2. Product behavior and slice boundaries are unchanged. +[A] Action: Implemented the typed empty `fleet.Source` path, build metadata, fail-safe configuration, +structured logging, deterministic CLI text/JSON rendering, UI/hub stubs, and a process-level binary +smoke suite. Updated the README from planning-only status to the runnable Slice 0 surface. +[T] Test: `make ci` passed with Go 1.25.12 and golangci-lint v2.12.2: gofmt/goimports, `go vet`, +11 strict linters, race-enabled unit tests, coverage, subprocess e2e tests, and the ldflags build. +Core package coverage is 81.1% CLI, 83.8% config, 83.3% buildinfo, 87.5% logging, and 100% fleet. +[T] Test: `govulncheck ./...` reported no vulnerabilities; `go mod verify`, 20 shuffled test +repetitions, action-SHA verification, forbidden-attribution/product-name scans, SPDX checks, and +manual command/exit-code smoke checks passed. The external CodeRabbit CLI was unavailable, so the +review remained local and no repository data was uploaded. +[C] Checkpoint #1: f9ae42d — Go module, dependency, and strict quality-tool baseline; next: core +packages. +[C] Checkpoint #2: 5383365 — buildinfo, config, logging, and typed fleet seam with tests; next: CLI. +[C] Checkpoint #3: 35f1190 — runnable Cobra walking skeleton and binary e2e suite; next: CI. +[C] Checkpoint #4: ab9f59b — least-privilege, SHA-pinned GitHub Actions merge gates; next: session +documentation and PR publication. +[C] Checkpoint #5: 5a488ed — README and GSTACK session record; next: push, PR into `dev`, and +remote CI/review. +[T] Test: PR #50's first CI run failed in the lint action because current action v9 supplies the +`run` subcommand itself; `args: run ./...` became `run run ./...` and treated `run/` as a package. +The product build and local lint remained green. +[A] Action: Corrected the action input to `args: ./...`, matching the current official action +contract while preserving the exact local `golangci-lint run ./...` gate. +[C] Checkpoint #6: dd738d7 — repair the remote lint-action invocation; next: push and re-run CI. +[T] Test: PR #50's repaired CI run 29116977271 passed all remote gates. A requirement audit then +identified that ldflags injection through `make build` had only manual evidence, not an automated +assertion. +[A] Action: Added a subprocess integration test that invokes the real Makefile with deterministic +version, commit, and date values, then parses `sith version -o json` and asserts all three fields. +[C] Checkpoint #7: this commit — automate the Makefile metadata acceptance criterion; next: push, +re-run CI, and await independent review. + +--- + +**Session close:** implementation complete; remote CI/review pending · **Open questions touched:** none diff --git a/sessions/JOURNAL-TEMPLATE.md b/sessions/JOURNAL-TEMPLATE.md new file mode 100644 index 0000000..6ce7462 --- /dev/null +++ b/sessions/JOURNAL-TEMPLATE.md @@ -0,0 +1,18 @@ +# Session — + +**Builder:** · **Model/effort:** · **Branch:** feat/ +**Slice(s):** · **Status:** in-progress | done | blocked + +--- + +[G] Goal: +[S] Scope: +[A] Action: +[T] Test: +[C] Checkpoint #1: ; next: + + + +--- + +**Session close:** · **Open questions touched:** diff --git a/sessions/README.md b/sessions/README.md new file mode 100644 index 0000000..bef7ac0 --- /dev/null +++ b/sessions/README.md @@ -0,0 +1,29 @@ +# Sith session journals (GSTACK) + +This directory is Sith's **session journal**, kept under the GSTACK discipline defined in +[`../docs/CONVENTIONS.md`](../docs/CONVENTIONS.md) §3. + +Every build session (a continuous stretch of work by one builder) copies +[`JOURNAL-TEMPLATE.md`](JOURNAL-TEMPLATE.md) to `YYYY-MM-DD-.md` and appends entries as +it works, so the next session — human or agent — resumes with full context and every checkpoint maps +to a commit. + +## Entry markers + +| Marker | Name | Records | +|---|---|---| +| `[G]` | Goal | The objective of the work unit + issue number(s). | +| `[S]` | Scope | Files/packages in play; what is explicitly out. | +| `[A]` | Action | What was actually done. | +| `[T]` | Test | How it was verified + result. | +| `[C]` | Checkpoint | A numbered milestone: commit SHA(s), decision, next step. `#` matches the commit's `GSTACK-Checkpoint` trailer. | + +The journal is the **stack** of these G/S/A/T/C entries — hence *GSTACK*. + +## Rules + +- Start a session by copying the template; append as you go (do not reconstruct at the end). +- Each `[C]` checkpoint ⇄ exactly one commit carrying `GSTACK-Checkpoint: YYYY-MM-DD/#`. +- Record which open questions (Q12–Q15, `docs/SITH-NOTION.md` §9) a slice touched and the default chosen. +- This directory is committed (engineering history). **Never** put secrets, tokens, kubeconfigs, or + customer data in a journal. diff --git a/tests/e2e/smoke_test.go b/tests/e2e/smoke_test.go new file mode 100644 index 0000000..1f198e7 --- /dev/null +++ b/tests/e2e/smoke_test.go @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +//go:build e2e + +// Package e2e_test verifies the compiled Sith process boundary. +package e2e_test + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestBinarySmoke(t *testing.T) { + root := repositoryRoot(t) + binary := filepath.Join(t.TempDir(), "sith") + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + build := exec.CommandContext(ctx, "go", "build", "-trimpath", "-o", binary, "./cmd/sith") + build.Dir = root + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build binary: %v\n%s", err, output) + } + + tests := []struct { + name string + args []string + contains string + validJSON bool + }{ + {name: "version text", args: []string{"version"}, contains: "sith dev"}, + {name: "version JSON", args: []string{"version", "-o", "json"}, validJSON: true}, + {name: "clusters text", args: []string{"clusters"}, contains: "No clusters found"}, + {name: "clusters JSON", args: []string{"clusters", "-o", "json"}, validJSON: true}, + {name: "ui stub", args: []string{"ui"}, contains: "not yet implemented"}, + {name: "hub stub", args: []string{"hub"}, contains: "phase-1+"}, + {name: "no arguments", contains: "Usage:"}, + {name: "help", args: []string{"--help"}, contains: "Usage:"}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + command := exec.CommandContext(ctx, binary, test.args...) + command.Env = append(os.Environ(), "XDG_CONFIG_HOME="+t.TempDir()) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("run %v: %v\n%s", test.args, err, output) + } + if test.contains != "" && !strings.Contains(string(output), test.contains) { + t.Fatalf("output = %q, want %q", output, test.contains) + } + if test.validJSON && !json.Valid(output) { + t.Fatalf("output is not valid JSON: %q", output) + } + }) + } +} + +func TestUnknownCommandFails(t *testing.T) { + t.Parallel() + + root := repositoryRoot(t) + binary := filepath.Join(t.TempDir(), "sith") + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + build := exec.CommandContext(ctx, "go", "build", "-trimpath", "-o", binary, "./cmd/sith") + build.Dir = root + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build binary: %v\n%s", err, output) + } + + command := exec.CommandContext(ctx, binary, "bogus") + output, err := command.CombinedOutput() + if err == nil { + t.Fatalf("bogus command succeeded: %s", output) + } + if !strings.Contains(string(output), "unknown command") { + t.Fatalf("output = %q, want unknown command", output) + } +} + +func TestMakeBuildInjectsMetadata(t *testing.T) { + root := repositoryRoot(t) + binDir := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + const ( + version = "v0.0.0-e2e" + commit = "abc1234" + date = "2026-07-10T19:00:00Z" + ) + build := exec.CommandContext( + ctx, + "make", + "build", + "BIN_DIR="+binDir, + "VERSION="+version, + "COMMIT="+commit, + "DATE="+date, + ) + build.Dir = root + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("make build: %v\n%s", err, output) + } + + binary := filepath.Join(binDir, "sith") + command := exec.CommandContext(ctx, binary, "version", "-o", "json") + output, err := command.Output() + if err != nil { + t.Fatalf("run built binary: %v", err) + } + + var got struct { + Version string `json:"version"` + Commit string `json:"commit"` + Date string `json:"date"` + } + if err := json.Unmarshal(output, &got); err != nil { + t.Fatalf("unmarshal version output %q: %v", output, err) + } + if got.Version != version || got.Commit != commit || got.Date != date { + t.Fatalf("metadata = %#v, want version=%q commit=%q date=%q", got, version, commit, date) + } +} + +func repositoryRoot(t *testing.T) string { + t.Helper() + + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve current test file") + } + + return filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..", "..")) +}