diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 372c21d..1adfb3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,9 @@ concurrency: cancel-in-progress: true env: - GO_VERSION: "1.25.x" + GO_VERSION: "1.26.x" GOLANGCI_VERSION: "v2.12.2" + GOVULNCHECK_VERSION: "v1.6.0" jobs: build-test-lint: @@ -59,6 +60,11 @@ jobs: - name: Format and imports check run: golangci-lint fmt --diff ./... + - name: Vulnerability scan + run: | + go install golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION} + govulncheck ./... + - name: Build run: go build -trimpath ./... @@ -67,3 +73,9 @@ jobs: - name: Binary integration smoke test run: go test -race -count=1 -tags=e2e ./tests/e2e + + - name: Install pinned kind + run: go install sigs.k8s.io/kind@v0.32.0 + + - name: Real two-cluster fan-out test + run: make e2e-kind KIND="$(go env GOPATH)/bin/kind" diff --git a/.gitignore b/.gitignore index 6e5ea02..5481b22 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ Thumbs.db *.key *.pem *.pfx -kubeconfig +/kubeconfig *.kubeconfig secrets/ .secrets/ diff --git a/.golangci.yml b/.golangci.yml index 1bf0774..9e6bc5f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,6 +3,9 @@ version: "2" run: timeout: 5m tests: true + build-tags: + - e2e + - kind linters: default: none diff --git a/Makefile b/Makefile index f36024d..f326495 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,10 @@ PKG := github.com/ArdurAI/sith CMD := ./cmd/sith BIN_DIR := bin GOLANGCI ?= golangci-lint +GOVULNCHECK ?= govulncheck +KIND ?= kind + +KIND_NODE_IMAGE ?= kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5 VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) @@ -16,7 +20,7 @@ LDFLAGS := -s -w \ -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 +.PHONY: all build test e2e e2e-kind lint vuln fmt fmt-check vet tidy clean run ci help all: build @@ -30,9 +34,16 @@ test: ## Run unit tests with the race detector and report coverage e2e: ## Build and exercise the real binary as a subprocess go test -race -count=1 -tags=e2e ./tests/e2e +e2e-kind: ## Exercise adapter and binary against two real kind clusters + KIND_BIN="$(KIND)" KIND_NODE_IMAGE="$(KIND_NODE_IMAGE)" \ + go test -race -count=1 -timeout=15m -tags='e2e kind' -run '^TestKindFleetFanout$$' ./tests/e2e + lint: ## Run golangci-lint (v2) $(GOLANGCI) run ./... +vuln: ## Scan reachable Go call paths for known vulnerabilities + $(GOVULNCHECK) ./... + fmt: ## Format code (gofmt + goimports via golangci-lint v2 formatters) $(GOLANGCI) fmt ./... @@ -53,7 +64,7 @@ clean: ## Remove build and coverage artifacts 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 +ci: fmt-check vet lint vuln test e2e build ## Run the full CI gate locally help: ## List targets @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \ diff --git a/README.md b/README.md index 1280e1e..a79690d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Sith -**Status: Slice 0 foundation.** The local-first CLI walking skeleton is runnable; Kubernetes -context discovery arrives in Slice 1. +**Status: Slice 1 local fleet source.** The CLI discovers every context resolved by client-go, +probes them independently, and reports reachable and unreachable clusters without a hub. 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 @@ -10,7 +10,7 @@ governed hub. ## Build and run -Sith requires a supported Go 1.25 toolchain. +Sith requires a supported Go 1.26 toolchain. ```bash make build @@ -19,12 +19,23 @@ make build ./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`: +`sith clusters` follows standard client-go loading rules: set `KUBECONFIG` to an OS path-list or +use the default `~/.kube/config`. Exec-credential helpers run locally, exactly as they do for +`kubectl`; Sith does not copy kubeconfigs or credentials elsewhere. + +Run the full local quality gate with golangci-lint v2.12.2 and govulncheck v1.6.0 on `PATH`: ```bash make ci ``` +The real multi-cluster gate creates two temporary kind clusters with a digest-pinned node image, +checks one additional unreachable context, and removes both clusters afterward. It requires a +running Docker engine and kind v0.32.0, and consumes additional CI time, disk, and memory: + +```bash +make e2e-kind +``` + The architecture, threat model, ADRs, and roadmap live under [`docs/`](docs/). Build-session checkpoints are recorded under [`sessions/`](sessions/). diff --git a/go.mod b/go.mod index da62d11..d4d5d13 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,50 @@ module github.com/ArdurAI/sith -go 1.25.0 +go 1.26.0 require ( github.com/spf13/cobra v1.10.2 go.yaml.in/yaml/v3 v3.0.4 + k8s.io/apimachinery v0.36.2 + k8s.io/client-go v0.36.2 ) require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.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 + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.36.2 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index e63b363..f30273e 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,121 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/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= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= 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= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY= +k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg= +k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ= +k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4= +k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI= +k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/cli/root.go b/internal/cli/root.go index bea3098..2e89479 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -13,6 +13,8 @@ import ( "github.com/spf13/cobra" "github.com/ArdurAI/sith/internal/config" + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/connector/kubeconfig" "github.com/ArdurAI/sith/internal/fleet" "github.com/ArdurAI/sith/internal/logging" ) @@ -33,7 +35,7 @@ type rootOptions struct { // 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) + return execute(os.Args[1:], connector.AsSource(kubeconfig.Default()), os.Stdout, os.Stderr) } func execute(args []string, source fleet.Source, stdout, stderr io.Writer) int { diff --git a/internal/connector/contract.go b/internal/connector/contract.go new file mode 100644 index 0000000..4c022af --- /dev/null +++ b/internal/connector/contract.go @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package connector defines Sith's capability-scoped source-adapter contract. +package connector + +import ( + "context" + "encoding/json" + "time" + + "github.com/ArdurAI/sith/internal/fleet" +) + +// Connector identifies one canonical integration and its declared capabilities. +type Connector interface { + Kind() string + Capabilities() []Capability + Descriptor() Descriptor +} + +// Descriptor is static registry, taxonomy, ownership, and version metadata. +type Descriptor struct { + Kind string `json:"kind"` + ConnKind ConnectorKind `json:"connector_kind"` + ProtocolV string `json:"protocol_version"` + Owner string `json:"owner"` + Capabilities []Capability `json:"capabilities"` + Verbs []string `json:"verbs,omitempty"` +} + +// ConnectorKind is the closed integration taxonomy. +// +//nolint:revive // ConnectorKind is the locked cross-connector contract name from issue #38. +type ConnectorKind string + +// Supported connector kinds. +const ( + KindReadAdapter ConnectorKind = "read-adapter" + KindBrokeredRead ConnectorKind = "brokered-read-through" + KindTypedAction ConnectorKind = "typed-action" +) + +// Valid reports whether the connector kind belongs to the closed taxonomy. +func (kind ConnectorKind) Valid() bool { + switch kind { + case KindReadAdapter, KindBrokeredRead, KindTypedAction: + return true + default: + return false + } +} + +// Capability names one verb a connector explicitly opts into. +type Capability string + +// Supported connector capabilities. +const ( + CapDiscover Capability = "discover" + CapRead Capability = "read" + CapQuery Capability = "query" + CapDiff Capability = "diff" + CapPlan Capability = "plan" + CapExecute Capability = "execute" + CapVerify Capability = "verify" +) + +// Valid reports whether the capability belongs to the seven-verb contract. +func (capability Capability) Valid() bool { + switch capability { + case CapDiscover, CapRead, CapQuery, CapDiff, CapPlan, CapExecute, CapVerify: + return true + default: + return false + } +} + +// Reader implements the discover, read, and query half of the connector contract. +type Reader interface { + Connector + Discover(ctx context.Context) (Discovery, error) + Read(ctx context.Context, ref fleet.ResourceRef) (fleet.Evidence, error) + Query(ctx context.Context, query fleet.Query) (fleet.QueryResult, error) +} + +// Discovery describes the scopes a reader can currently address. +type Discovery struct { + Scopes []Scope `json:"scopes"` + Unreachable []string `json:"unreachable,omitempty"` +} + +// Scope is one cluster, context, or spoke exposed by a reader. +type Scope struct { + Name string `json:"name"` + Kinds []string `json:"kinds"` + Reachable bool `json:"reachable"` + ObservedAt time.Time `json:"observed_at,omitempty"` +} + +// Differ computes desired-versus-observed state without mutation. +type Differ interface { + Connector + Diff(ctx context.Context, request DiffRequest) (fleet.Diff, error) +} + +// Planner converts a validated typed intent into an inspectable dry-run plan. +type Planner interface { + Connector + Plan(ctx context.Context, intent Intent) (ActionPlan, error) +} + +// Executor applies a previously approved action plan through the governed path. +type Executor interface { + Connector + Execute(ctx context.Context, plan ActionPlan) (ExecutionResult, error) +} + +// Verifier checks post-conditions after an execution. +type Verifier interface { + Connector + Verify(ctx context.Context, request VerifyRequest) (Verification, error) +} + +// Intent is a validated, signed request from the closed action vocabulary. +type Intent struct { + ID string `json:"id"` + Workspace string `json:"workspace"` + Actor string `json:"actor"` + Verb string `json:"verb"` + Target fleet.ResourceRef `json:"target"` + Args json.RawMessage `json:"args"` + Justification string `json:"justification"` + EvidenceRefs []fleet.ResourceRef `json:"evidence_refs,omitempty"` + Signature string `json:"signature"` +} + +// ActionPlan is the non-mutating, inspectable result of planning an intent. +type ActionPlan struct { + IntentID string `json:"intent_id"` + Verb string `json:"verb"` + Target fleet.ResourceRef `json:"target"` + Diff fleet.Diff `json:"diff"` + Steps []PlanStep `json:"steps"` + Reversible bool `json:"reversible"` + Warnings []string `json:"warnings,omitempty"` +} + +// PlanStep is one ordered typed API call; it is never a shell command. +type PlanStep struct { + Description string `json:"description"` + API string `json:"api"` + Params json.RawMessage `json:"params"` +} + +// ExecutionResult records the observed outcome of an approved plan. +type ExecutionResult struct { + IntentID string `json:"intent_id"` + Applied bool `json:"applied"` + StepsDone int `json:"steps_done"` + Observed fleet.Evidence `json:"observed"` + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at"` + Err string `json:"err,omitempty"` +} + +// VerifyRequest is a typed post-condition assertion. +type VerifyRequest struct { + IntentID string `json:"intent_id"` + Target fleet.ResourceRef `json:"target"` + Expect fleet.Selector `json:"expect"` +} + +// Verification is the observed verdict for a post-condition. +type Verification struct { + Satisfied bool `json:"satisfied"` + Observed fleet.Evidence `json:"observed"` + Detail string `json:"detail,omitempty"` + CheckedAt time.Time `json:"checked_at"` +} + +// DiffRequest asks a connector to compare desired and observed state. +type DiffRequest struct { + Target fleet.ResourceRef `json:"target"` + Desired json.RawMessage `json:"desired,omitempty"` +} + +// ValidVerb reports whether a verb belongs to the reviewed initial action vocabulary. +func ValidVerb(verb string) bool { + switch verb { + case "argocd.sync", "argocd.rollback", + "rollout.promote", "rollout.abort", + "deployment.scale", "deployment.restart", + "gitops.open-pr": + return true + default: + return false + } +} diff --git a/internal/connector/kubeconfig/adapter.go b/internal/connector/kubeconfig/adapter.go new file mode 100644 index 0000000..574099c --- /dev/null +++ b/internal/connector/kubeconfig/adapter.go @@ -0,0 +1,418 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package kubeconfig implements the local kubeconfig source adapter. +package kubeconfig + +import ( + "context" + "fmt" + "sort" + "sync" + "time" + + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/ArdurAI/sith/internal/connector" +) + +const ( + // Kind is the stable registry identifier for the local kubeconfig adapter. + Kind = "local-kubeconfig" + + protocolVersion = "1.0.0" + defaultProbeTimeout = 2 * time.Second + defaultRequestTimeout = 10 * time.Second + defaultStaleAfter = 2 * time.Minute + defaultConcurrency = 16 +) + +var supportedKinds = []string{ + "Deployment", + "ReplicaSet", + "Pod", + "Rollout", + "Node", + "Service", + "Namespace", + "Event", +} + +type probeFunc func(ctx context.Context, config *rest.Config) error +type dynamicFactory func(config *rest.Config) (dynamic.Interface, error) + +type options struct { + loadingRules *clientcmd.ClientConfigLoadingRules + probeTimeout time.Duration + requestTimeout time.Duration + staleAfter time.Duration + maxConcurrency int + now func() time.Time + probe probeFunc + dynamic dynamicFactory +} + +// Option configures the local kubeconfig adapter. +type Option func(*options) error + +// WithLoadingRules replaces client-go's default kubeconfig loading rules. +func WithLoadingRules(rules *clientcmd.ClientConfigLoadingRules) Option { + return func(settings *options) error { + if rules == nil { + return fmt.Errorf("kubeconfig loading rules must not be nil") + } + copyRules := *rules + settings.loadingRules = ©Rules + return nil + } +} + +// WithExplicitPath reads one explicitly selected kubeconfig path. +func WithExplicitPath(path string) Option { + return func(settings *options) error { + if path != "" { + settings.loadingRules.ExplicitPath = path + } + return nil + } +} + +// WithProbeTimeout sets the independent reachability deadline for each context. +func WithProbeTimeout(timeout time.Duration) Option { + return func(settings *options) error { + if timeout <= 0 { + return fmt.Errorf("probe timeout must be positive") + } + settings.probeTimeout = timeout + return nil + } +} + +// WithRequestTimeout sets the deadline for resource reads and queries. +func WithRequestTimeout(timeout time.Duration) Option { + return func(settings *options) error { + if timeout <= 0 { + return fmt.Errorf("request timeout must be positive") + } + settings.requestTimeout = timeout + return nil + } +} + +// WithMaxConcurrency bounds simultaneous context operations. +func WithMaxConcurrency(limit int) Option { + return func(settings *options) error { + if limit <= 0 { + return fmt.Errorf("maximum concurrency must be positive") + } + settings.maxConcurrency = limit + return nil + } +} + +func withClock(now func() time.Time) Option { + return func(settings *options) error { + if now == nil { + return fmt.Errorf("clock must not be nil") + } + settings.now = now + return nil + } +} + +func withProbe(probe probeFunc) Option { + return func(settings *options) error { + if probe == nil { + return fmt.Errorf("probe must not be nil") + } + settings.probe = probe + return nil + } +} + +func withDynamicFactory(factory dynamicFactory) Option { + return func(settings *options) error { + if factory == nil { + return fmt.Errorf("dynamic client factory must not be nil") + } + settings.dynamic = factory + return nil + } +} + +// Adapter discovers contexts and performs independent local client-go reads. +type Adapter struct { + settings options + + mu sync.RWMutex + discovered bool + scopes map[string]connector.Scope + clients map[string]dynamic.Interface + lastSeen map[string]time.Time +} + +var _ connector.Reader = (*Adapter)(nil) + +// Default constructs an adapter using client-go's KUBECONFIG and home-directory rules. +func Default() *Adapter { + return newAdapter(defaultOptions()) +} + +// New constructs a local kubeconfig adapter without performing network I/O. +func New(opts ...Option) (*Adapter, error) { + settings := defaultOptions() + for _, option := range opts { + if option == nil { + return nil, fmt.Errorf("configure local kubeconfig adapter: option is nil") + } + if err := option(&settings); err != nil { + return nil, fmt.Errorf("configure local kubeconfig adapter: %w", err) + } + } + + return newAdapter(settings), nil +} + +func defaultOptions() options { + return options{ + loadingRules: clientcmd.NewDefaultClientConfigLoadingRules(), + probeTimeout: defaultProbeTimeout, + requestTimeout: defaultRequestTimeout, + staleAfter: defaultStaleAfter, + maxConcurrency: defaultConcurrency, + now: time.Now, + probe: defaultProbe, + dynamic: func(config *rest.Config) (dynamic.Interface, error) { + return dynamic.NewForConfig(config) + }, + } +} + +func newAdapter(settings options) *Adapter { + return &Adapter{ + settings: settings, + scopes: make(map[string]connector.Scope), + clients: make(map[string]dynamic.Interface), + lastSeen: make(map[string]time.Time), + } +} + +// Kind identifies this connector in the registry and resource address space. +func (*Adapter) Kind() string { + return Kind +} + +// Capabilities declares the read-only verbs implemented by local kubeconfig. +func (*Adapter) Capabilities() []connector.Capability { + return []connector.Capability{connector.CapDiscover, connector.CapRead, connector.CapQuery} +} + +// Descriptor returns immutable registration metadata for this adapter. +func (adapter *Adapter) Descriptor() connector.Descriptor { + return connector.Descriptor{ + Kind: adapter.Kind(), + ConnKind: connector.KindReadAdapter, + ProtocolV: protocolVersion, + Owner: "sith-core", + Capabilities: adapter.Capabilities(), + } +} + +// Discover enumerates every context and probes each independently. +func (adapter *Adapter) Discover(ctx context.Context) (connector.Discovery, error) { + rawConfig, err := adapter.settings.loadingRules.Load() + if err != nil { + return connector.Discovery{}, fmt.Errorf("load kubeconfig: %w", err) + } + + names := make([]string, 0, len(rawConfig.Contexts)) + for name := range rawConfig.Contexts { + names = append(names, name) + } + sort.Strings(names) + priorLastSeen := adapter.lastSeenSnapshot() + + results := make([]contextResult, len(names)) + adapter.runBounded(len(names), func(index int) { + results[index] = adapter.probeContext(ctx, *rawConfig, names[index], priorLastSeen[names[index]]) + }) + if err := ctx.Err(); err != nil { + return connector.Discovery{}, fmt.Errorf("discover kubeconfig contexts: %w", err) + } + + scopes := make([]connector.Scope, 0, len(results)) + unreachable := make([]string, 0) + clients := make(map[string]dynamic.Interface, len(results)) + lastSeen := make(map[string]time.Time, len(results)) + for _, result := range results { + scopes = append(scopes, result.scope) + if result.scope.Reachable { + clients[result.scope.Name] = result.client + } else { + unreachable = append(unreachable, result.scope.Name) + } + if !result.scope.ObservedAt.IsZero() { + lastSeen[result.scope.Name] = result.scope.ObservedAt + } + } + + adapter.mu.Lock() + adapter.discovered = true + adapter.scopes = make(map[string]connector.Scope, len(scopes)) + for _, scope := range scopes { + adapter.scopes[scope.Name] = cloneScope(scope) + } + adapter.clients = clients + adapter.lastSeen = lastSeen + adapter.mu.Unlock() + + return connector.Discovery{Scopes: cloneScopes(scopes), Unreachable: append([]string(nil), unreachable...)}, nil +} + +type contextResult struct { + scope connector.Scope + client dynamic.Interface +} + +func (adapter *Adapter) probeContext( + ctx context.Context, + rawConfig clientcmdapi.Config, + name string, + lastSeen time.Time, +) contextResult { + scope := connector.Scope{ + Name: name, + Kinds: append([]string(nil), supportedKinds...), + ObservedAt: lastSeen, + } + clientConfig := clientcmd.NewNonInteractiveClientConfig( + rawConfig, + name, + &clientcmd.ConfigOverrides{}, + adapter.settings.loadingRules, + ) + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return contextResult{scope: scope} + } + restConfig.UserAgent = "sith/" + protocolVersion + + probeConfig := rest.CopyConfig(restConfig) + probeConfig.Timeout = adapter.settings.probeTimeout + _, err = callWithTimeout(ctx, adapter.settings.probeTimeout, func(probeCtx context.Context) (struct{}, error) { + return struct{}{}, adapter.settings.probe(probeCtx, probeConfig) + }) + if err != nil { + return contextResult{scope: scope} + } + + requestConfig := rest.CopyConfig(restConfig) + requestConfig.Timeout = adapter.settings.requestTimeout + client, err := adapter.settings.dynamic(requestConfig) + if err != nil { + return contextResult{scope: scope} + } + + scope.Reachable = true + scope.ObservedAt = adapter.settings.now().UTC() + return contextResult{scope: scope, client: client} +} + +func (adapter *Adapter) runBounded(count int, operation func(index int)) { + if count == 0 { + return + } + workers := min(adapter.settings.maxConcurrency, count) + jobs := make(chan int) + var waitGroup sync.WaitGroup + waitGroup.Add(workers) + for range workers { + go func() { + defer waitGroup.Done() + for index := range jobs { + operation(index) + } + }() + } + for index := range count { + jobs <- index + } + close(jobs) + waitGroup.Wait() +} + +type operationResult[T any] struct { + value T + err error +} + +func callWithTimeout[T any]( + ctx context.Context, + timeout time.Duration, + operation func(context.Context) (T, error), +) (T, error) { + operationCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + result := make(chan operationResult[T], 1) + // client-go's exec authenticator uses exec.Command rather than CommandContext. Isolating the + // call keeps one auth helper that ignores cancellation from stalling the rest of the fleet. + go func() { + value, err := operation(operationCtx) + result <- operationResult[T]{value: value, err: err} + }() + select { + case completed := <-result: + return completed.value, completed.err + case <-operationCtx.Done(): + var zero T + return zero, operationCtx.Err() + } +} + +func (adapter *Adapter) ensureDiscovered(ctx context.Context) error { + adapter.mu.RLock() + discovered := adapter.discovered + adapter.mu.RUnlock() + if discovered { + return nil + } + _, err := adapter.Discover(ctx) + return err +} + +func (adapter *Adapter) lastSeenSnapshot() map[string]time.Time { + adapter.mu.RLock() + defer adapter.mu.RUnlock() + result := make(map[string]time.Time, len(adapter.lastSeen)) + for name, observed := range adapter.lastSeen { + result[name] = observed + } + return result +} + +func defaultProbe(ctx context.Context, config *rest.Config) error { + client, err := discovery.NewDiscoveryClientForConfig(config) + if err != nil { + return fmt.Errorf("create discovery client: %w", err) + } + if err := client.RESTClient().Get().AbsPath("/version").Do(ctx).Error(); err != nil { + return fmt.Errorf("query API version: %w", err) + } + return nil +} + +func cloneScope(scope connector.Scope) connector.Scope { + scope.Kinds = append([]string(nil), scope.Kinds...) + return scope +} + +func cloneScopes(scopes []connector.Scope) []connector.Scope { + result := make([]connector.Scope, 0, len(scopes)) + for _, scope := range scopes { + result = append(result, cloneScope(scope)) + } + return result +} diff --git a/internal/connector/kubeconfig/adapter_test.go b/internal/connector/kubeconfig/adapter_test.go new file mode 100644 index 0000000..104f372 --- /dev/null +++ b/internal/connector/kubeconfig/adapter_test.go @@ -0,0 +1,413 @@ +// SPDX-License-Identifier: Apache-2.0 + +package kubeconfig + +import ( + "context" + "encoding/json" + "encoding/pem" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "sync" + "testing" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + dynamicfake "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/rest" + k8stesting "k8s.io/client-go/testing" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/ArdurAI/sith/internal/fleet" +) + +func TestNewRejectsInvalidOptions(t *testing.T) { + t.Parallel() + tests := []struct { + name string + option Option + }{ + {name: "nil option", option: nil}, + {name: "nil loading rules", option: WithLoadingRules(nil)}, + {name: "zero probe timeout", option: WithProbeTimeout(0)}, + {name: "zero request timeout", option: WithRequestTimeout(0)}, + {name: "zero concurrency", option: WithMaxConcurrency(0)}, + {name: "nil clock", option: withClock(nil)}, + {name: "nil probe", option: withProbe(nil)}, + {name: "nil dynamic factory", option: withDynamicFactory(nil)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if _, err := New(test.option); err == nil { + t.Fatal("New() error = nil, want invalid option error") + } + }) + } +} + +func TestDiscoverIsIndependentAndPreservesLastSeen(t *testing.T) { + t.Parallel() + firstObserved := time.Date(2026, time.July, 10, 12, 0, 0, 0, time.UTC) + secondObserved := firstObserved.Add(5 * time.Minute) + currentTime := firstObserved + var stateMu sync.Mutex + failures := map[string]bool{} + clients := map[string]*dynamicfake.FakeDynamicClient{ + "https://alpha.invalid": fakeClient(), + "https://beta.invalid": fakeClient(), + } + + adapter, err := New( + WithLoadingRules(testLoadingRules(t, testConfig("alpha", "beta"))), + WithMaxConcurrency(2), + withClock(func() time.Time { + stateMu.Lock() + defer stateMu.Unlock() + return currentTime + }), + withProbe(func(_ context.Context, config *rest.Config) error { + stateMu.Lock() + defer stateMu.Unlock() + if failures[config.Host] { + return errors.New("synthetic reachability failure") + } + return nil + }), + withDynamicFactory(func(config *rest.Config) (dynamic.Interface, error) { + return clients[config.Host], nil + }), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + discovery, err := adapter.Discover(context.Background()) + if err != nil { + t.Fatalf("first Discover() error = %v", err) + } + if len(discovery.Scopes) != 2 || len(discovery.Unreachable) != 0 { + t.Fatalf("first Discover() = %#v, want two reachable scopes", discovery) + } + + stateMu.Lock() + currentTime = secondObserved + failures["https://beta.invalid"] = true + stateMu.Unlock() + discovery, err = adapter.Discover(context.Background()) + if err != nil { + t.Fatalf("second Discover() error = %v", err) + } + if !slices.Equal(discovery.Unreachable, []string{"beta"}) { + t.Fatalf("Unreachable = %v, want [beta]", discovery.Unreachable) + } + if !discovery.Scopes[0].Reachable || discovery.Scopes[0].ObservedAt != secondObserved { + t.Fatalf("alpha scope = %#v, want newly observed reachable scope", discovery.Scopes[0]) + } + if discovery.Scopes[1].Reachable || discovery.Scopes[1].ObservedAt != firstObserved { + t.Fatalf("beta scope = %#v, want unreachable scope preserving last seen", discovery.Scopes[1]) + } +} + +func TestDiscoverTimesOutProbeThatIgnoresContext(t *testing.T) { + t.Parallel() + release := make(chan struct{}) + adapter, err := New( + WithLoadingRules(testLoadingRules(t, testConfig("blocked"))), + WithProbeTimeout(20*time.Millisecond), + withProbe(func(_ context.Context, _ *rest.Config) error { + <-release + return nil + }), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + started := time.Now() + discovery, err := adapter.Discover(context.Background()) + close(release) + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("Discover() took %s, want bounded probe timeout", elapsed) + } + if !slices.Equal(discovery.Unreachable, []string{"blocked"}) { + t.Fatalf("Unreachable = %v, want [blocked]", discovery.Unreachable) + } +} + +func TestQueryAndReadReturnSourceStampedEvidenceWithPartialCoverage(t *testing.T) { + t.Parallel() + observedAt := time.Date(2026, time.July, 10, 13, 0, 0, 0, time.UTC) + alphaClient := fakeClient( + pod("api-0", "apps", "registry.example/api:v2", map[string]string{"app": "api"}), + pod("worker-0", "apps", "registry.example/worker:v1", map[string]string{"app": "worker"}), + ) + adapter, err := New( + WithLoadingRules(testLoadingRules(t, testConfig("alpha", "beta"))), + withClock(func() time.Time { return observedAt }), + withProbe(func(_ context.Context, config *rest.Config) error { + if config.Host == "https://beta.invalid" { + return errors.New("offline") + } + return nil + }), + withDynamicFactory(func(_ *rest.Config) (dynamic.Interface, error) { return alphaClient, nil }), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + result, err := adapter.Query(context.Background(), fleet.Query{ + Kinds: []fleet.FactKind{fleet.FactInventory}, + Scopes: []string{"alpha", "beta", "missing"}, + Selector: fleet.Selector{ + ResourceKind: "Pod", + Namespace: "apps", + NamePrefix: "api-", + Labels: map[string]string{"app": "api"}, + Image: "api:v2", + }, + }) + if err != nil { + t.Fatalf("Query() error = %v", err) + } + if result.Coverage.Requested != 3 || result.Coverage.Reachable != 1 { + t.Fatalf("Coverage = %#v, want one of three reachable", result.Coverage) + } + if !slices.Equal(result.Coverage.Unreachable, []string{"beta", "missing"}) { + t.Fatalf("Unreachable = %v, want [beta missing]", result.Coverage.Unreachable) + } + if len(result.Facts) != 1 { + t.Fatalf("Facts = %#v, want one selected pod", result.Facts) + } + fact := result.Facts[0] + if fact.Ref.SourceKind != Kind || fact.Ref.Scope != "alpha" || fact.Ref.Name != "api-0" { + t.Fatalf("Fact ref = %#v, want source-stamped alpha/api-0", fact.Ref) + } + if fact.Workspace != fleet.LocalWorkspace || fact.Provenance.Adapter != Kind { + t.Fatalf("Fact provenance = %#v, workspace = %q", fact.Provenance, fact.Workspace) + } + + evidence, err := adapter.Read(context.Background(), fact.Ref) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + if evidence.Ref.Name != "api-0" || evidence.ObservedAt != observedAt { + t.Fatalf("Read() evidence = %#v", evidence) + } + var object map[string]any + if err := json.Unmarshal(evidence.Observed, &object); err != nil { + t.Fatalf("unmarshal observed evidence: %v", err) + } + metadata, _ := object["metadata"].(map[string]any) + if metadata["name"] != "api-0" { + t.Fatalf("observed metadata = %#v, want api-0", metadata) + } + + _, err = adapter.Read(context.Background(), fleet.ResourceRef{Scope: "missing", Kind: "Pod", Name: "x"}) + if !errors.Is(err, ErrUnknownScope) { + t.Fatalf("Read(unknown) error = %v, want ErrUnknownScope", err) + } + _, err = adapter.Read(context.Background(), fleet.ResourceRef{Scope: "beta", Kind: "Pod", Name: "x"}) + if !errors.Is(err, ErrUnreachableScope) { + t.Fatalf("Read(unreachable) error = %v, want ErrUnreachableScope", err) + } +} + +func TestInvalidInputsFailBeforeDiscovery(t *testing.T) { + t.Parallel() + probeCalls := 0 + adapter, err := New( + WithLoadingRules(testLoadingRules(t, testConfig("alpha"))), + withProbe(func(_ context.Context, _ *rest.Config) error { + probeCalls++ + return nil + }), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + _, err = adapter.Read(context.Background(), fleet.ResourceRef{Scope: "alpha", Kind: "Pod"}) + if !errors.Is(err, ErrInvalidReference) { + t.Fatalf("Read(invalid) error = %v, want ErrInvalidReference", err) + } + _, err = adapter.Query(context.Background(), fleet.Query{ + Selector: fleet.Selector{ResourceKind: "Pod", Labels: map[string]string{"bad key": "value"}}, + }) + if !errors.Is(err, ErrUnsupportedSelector) { + t.Fatalf("Query(invalid label) error = %v, want ErrUnsupportedSelector", err) + } + if probeCalls != 0 { + t.Fatalf("probe calls = %d, want invalid inputs rejected before credential/network work", probeCalls) + } +} + +func TestQueryTimesOutClientThatIgnoresContext(t *testing.T) { + t.Parallel() + release := make(chan struct{}) + finished := make(chan struct{}) + client := fakeClient() + client.PrependReactor("list", "pods", func(_ k8stesting.Action) (bool, runtime.Object, error) { + <-release + close(finished) + return true, &unstructured.UnstructuredList{}, nil + }) + adapter, err := New( + WithLoadingRules(testLoadingRules(t, testConfig("alpha"))), + WithRequestTimeout(20*time.Millisecond), + withProbe(func(_ context.Context, _ *rest.Config) error { return nil }), + withDynamicFactory(func(_ *rest.Config) (dynamic.Interface, error) { return client, nil }), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + started := time.Now() + result, err := adapter.Query(context.Background(), fleet.Query{Selector: fleet.Selector{ResourceKind: "Pod"}}) + close(release) + <-finished + if err != nil { + t.Fatalf("Query() error = %v", err) + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("Query() took %s, want bounded request timeout", elapsed) + } + if result.Coverage.Reachable != 0 || !slices.Equal(result.Coverage.Unreachable, []string{"alpha"}) { + t.Fatalf("Coverage = %#v, want timed-out alpha surfaced as unreachable", result.Coverage) + } +} + +func TestDefaultProbeExecutesExecCredentialLocally(t *testing.T) { + if os.Getenv("SITH_EXEC_HELPER") == "1" { + runExecCredentialHelper() + } + + const token = "ephemeral-test-token" + marker := filepath.Join(t.TempDir(), "exec-called") + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/version" { + http.NotFound(writer, request) + return + } + if request.Header.Get("Authorization") != "Bearer "+token { + http.Error(writer, "unauthorized", http.StatusUnauthorized) + return + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"gitVersion":"v1.36.1"}`)) + })) + t.Cleanup(server.Close) + + certificate := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + config := clientcmdapi.Config{ + Clusters: map[string]*clientcmdapi.Cluster{ + "exec": {Server: server.URL, CertificateAuthorityData: certificate}, + }, + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "exec": {Exec: &clientcmdapi.ExecConfig{ + Command: os.Args[0], + Args: []string{"-test.run=TestDefaultProbeExecutesExecCredentialLocally"}, + APIVersion: "client.authentication.k8s.io/v1", + InteractiveMode: clientcmdapi.NeverExecInteractiveMode, + Env: []clientcmdapi.ExecEnvVar{ + {Name: "SITH_EXEC_HELPER", Value: "1"}, + {Name: "SITH_EXEC_MARKER", Value: marker}, + {Name: "SITH_EXEC_TOKEN", Value: token}, + }, + }}, + }, + Contexts: map[string]*clientcmdapi.Context{ + "exec": {Cluster: "exec", AuthInfo: "exec"}, + }, + CurrentContext: "exec", + } + adapter, err := New( + WithLoadingRules(testLoadingRules(t, config)), + withDynamicFactory(func(_ *rest.Config) (dynamic.Interface, error) { return fakeClient(), nil }), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + discovery, err := adapter.Discover(context.Background()) + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + if len(discovery.Scopes) != 1 || !discovery.Scopes[0].Reachable { + t.Fatalf("Discover() = %#v, want reachable exec context", discovery) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("exec marker: %v", err) + } +} + +func runExecCredentialHelper() { + marker := os.Getenv("SITH_EXEC_MARKER") + if marker == "" || os.WriteFile(marker, []byte("called"), 0o600) != nil { + os.Exit(2) + } + credential := map[string]any{ + "apiVersion": "client.authentication.k8s.io/v1", + "kind": "ExecCredential", + "status": map[string]any{"token": os.Getenv("SITH_EXEC_TOKEN")}, + } + if json.NewEncoder(os.Stdout).Encode(credential) != nil { + os.Exit(2) + } + os.Exit(0) +} + +func testLoadingRules(t *testing.T, config clientcmdapi.Config) *clientcmd.ClientConfigLoadingRules { + t.Helper() + path := filepath.Join(t.TempDir(), "config") + if err := clientcmd.WriteToFile(config, path); err != nil { + t.Fatalf("write test kubeconfig: %v", err) + } + return &clientcmd.ClientConfigLoadingRules{ExplicitPath: path} +} + +func testConfig(contexts ...string) clientcmdapi.Config { + config := clientcmdapi.NewConfig() + for _, name := range contexts { + config.Clusters[name] = &clientcmdapi.Cluster{Server: "https://" + name + ".invalid"} + config.AuthInfos[name] = &clientcmdapi.AuthInfo{} + config.Contexts[name] = &clientcmdapi.Context{Cluster: name, AuthInfo: name} + } + return *config +} + +func fakeClient(objects ...runtime.Object) *dynamicfake.FakeDynamicClient { + listKinds := map[schema.GroupVersionResource]string{ + {Version: "v1", Resource: "pods"}: "PodList", + } + return dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), listKinds, objects...) +} + +func pod(name, namespace, image string, labels map[string]string) *unstructured.Unstructured { + unstructuredLabels := make(map[string]any, len(labels)) + for key, value := range labels { + unstructuredLabels[key] = value + } + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{ + "name": name, + "namespace": namespace, + "uid": name + "-uid", + "labels": unstructuredLabels, + }, + "spec": map[string]any{ + "containers": []any{map[string]any{"name": "app", "image": image}}, + }, + }} +} diff --git a/internal/connector/kubeconfig/resources.go b/internal/connector/kubeconfig/resources.go new file mode 100644 index 0000000..09927c9 --- /dev/null +++ b/internal/connector/kubeconfig/resources.go @@ -0,0 +1,388 @@ +// SPDX-License-Identifier: Apache-2.0 + +package kubeconfig + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleet" +) + +// ErrUnknownScope reports a context that was not present during discovery. +var ErrUnknownScope = errors.New("kubeconfig scope is unknown") + +// ErrUnreachableScope reports a discovered context without a live client. +var ErrUnreachableScope = errors.New("kubeconfig scope is unreachable") + +// ErrUnsupportedResource reports a resource kind outside this adapter's typed map. +var ErrUnsupportedResource = errors.New("resource kind is unsupported") + +// ErrUnsupportedSelector reports a selector not yet expressible by this adapter. +var ErrUnsupportedSelector = errors.New("query selector is unsupported") + +// ErrInvalidReference reports a resource address that is incomplete or inconsistent. +var ErrInvalidReference = errors.New("resource reference is invalid") + +type resourceSpec struct { + kind string + gvr schema.GroupVersionResource + namespaced bool +} + +var resourceSpecs = map[string]resourceSpec{ + "deployment": {kind: "Deployment", gvr: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, namespaced: true}, + "deployments": {kind: "Deployment", gvr: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, namespaced: true}, + "replicaset": {kind: "ReplicaSet", gvr: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "replicasets"}, namespaced: true}, + "replicasets": {kind: "ReplicaSet", gvr: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "replicasets"}, namespaced: true}, + "pod": {kind: "Pod", gvr: schema.GroupVersionResource{Version: "v1", Resource: "pods"}, namespaced: true}, + "pods": {kind: "Pod", gvr: schema.GroupVersionResource{Version: "v1", Resource: "pods"}, namespaced: true}, + "node": {kind: "Node", gvr: schema.GroupVersionResource{Version: "v1", Resource: "nodes"}}, + "nodes": {kind: "Node", gvr: schema.GroupVersionResource{Version: "v1", Resource: "nodes"}}, + "service": {kind: "Service", gvr: schema.GroupVersionResource{Version: "v1", Resource: "services"}, namespaced: true}, + "services": {kind: "Service", gvr: schema.GroupVersionResource{Version: "v1", Resource: "services"}, namespaced: true}, + "namespace": {kind: "Namespace", gvr: schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}}, + "namespaces": {kind: "Namespace", gvr: schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}}, + "event": {kind: "Event", gvr: schema.GroupVersionResource{Version: "v1", Resource: "events"}, namespaced: true}, + "events": {kind: "Event", gvr: schema.GroupVersionResource{Version: "v1", Resource: "events"}, namespaced: true}, + "rollout": {kind: "Rollout", gvr: schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "rollouts"}, namespaced: true}, + "rollouts": {kind: "Rollout", gvr: schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "rollouts"}, namespaced: true}, +} + +// Read fetches one resource from its explicitly addressed context. +func (adapter *Adapter) Read(ctx context.Context, ref fleet.ResourceRef) (fleet.Evidence, error) { + if ref.SourceKind != "" && ref.SourceKind != Kind { + return fleet.Evidence{}, fmt.Errorf("%w: source kind %q", ErrUnsupportedResource, ref.SourceKind) + } + if strings.TrimSpace(ref.Scope) == "" || strings.TrimSpace(ref.Name) == "" { + return fleet.Evidence{}, fmt.Errorf("%w: scope and name are required", ErrInvalidReference) + } + + spec, ok := lookupResource(ref.Kind) + if !ok { + return fleet.Evidence{}, fmt.Errorf("%w: %q", ErrUnsupportedResource, ref.Kind) + } + if expected := ref.Attributes["gvr"]; expected != "" && expected != spec.gvr.String() { + return fleet.Evidence{}, fmt.Errorf("%w: GVR %q does not match kind %q", ErrUnsupportedResource, expected, ref.Kind) + } + if err := adapter.ensureDiscovered(ctx); err != nil { + return fleet.Evidence{}, err + } + + scope, client, ok := adapter.scopeClient(ref.Scope) + if !ok { + return fleet.Evidence{}, fmt.Errorf("%w: %s", ErrUnknownScope, ref.Scope) + } + if !scope.Reachable || client == nil { + return fleet.Evidence{}, fmt.Errorf("%w: %s", ErrUnreachableScope, ref.Scope) + } + + resource := resourceInterface(client, spec, ref.Namespace) + object, err := callWithTimeout(ctx, adapter.settings.requestTimeout, func(requestCtx context.Context) (*unstructured.Unstructured, error) { + return resource.Get(requestCtx, ref.Name, metav1.GetOptions{}) + }) + if err != nil { + return fleet.Evidence{}, fmt.Errorf("read %s: %w", ref.String(), err) + } + observedAt := adapter.settings.now().UTC() + evidence, err := evidenceFromObject(*object, spec, ref.Scope, observedAt) + if err != nil { + return fleet.Evidence{}, err + } + adapter.recordLastSeen(ref.Scope, observedAt) + return evidence, nil +} + +// Query fans a typed resource selection out across independent contexts. +func (adapter *Adapter) Query(ctx context.Context, query fleet.Query) (fleet.QueryResult, error) { + if err := query.Validate(); err != nil { + return fleet.QueryResult{}, fmt.Errorf("validate fleet query: %w", err) + } + if query.Selector.CVE != "" || query.Selector.Health != "" { + return fleet.QueryResult{}, fmt.Errorf("%w: health and CVE predicates arrive in later slices", ErrUnsupportedSelector) + } + var spec resourceSpec + if query.Selector.ResourceKind != "" { + var ok bool + spec, ok = lookupResource(query.Selector.ResourceKind) + if !ok { + return fleet.QueryResult{}, fmt.Errorf("%w: %q", ErrUnsupportedResource, query.Selector.ResourceKind) + } + if !spec.namespaced && query.Selector.Namespace != "" { + return fleet.QueryResult{}, fmt.Errorf("%w: namespace cannot select cluster-scoped %s", ErrUnsupportedSelector, spec.kind) + } + } + labelSelector, err := labels.ValidatedSelectorFromSet(query.Selector.Labels) + if err != nil { + return fleet.QueryResult{}, fmt.Errorf("%w: invalid Kubernetes label selector: %v", ErrUnsupportedSelector, err) + } + if err := adapter.ensureDiscovered(ctx); err != nil { + return fleet.QueryResult{}, err + } + + scopes, clients, lastSeen := adapter.stateSnapshot() + targets := targetScopeNames(query.Scopes, scopes) + results := make([]scopeQueryResult, len(targets)) + adapter.runBounded(len(targets), func(index int) { + name := targets[index] + result, err := callWithTimeout(ctx, adapter.settings.requestTimeout, func(requestCtx context.Context) (scopeQueryResult, error) { + return adapter.queryScope(requestCtx, name, clients[name], spec, labelSelector.String(), query), nil + }) + if err != nil { + result = scopeQueryResult{name: name, err: err} + } + results[index] = result + }) + if err := ctx.Err(); err != nil { + return fleet.QueryResult{}, fmt.Errorf("query kubeconfig contexts: %w", err) + } + + now := adapter.settings.now().UTC() + coverage := fleet.Coverage{Requested: len(targets)} + facts := make([]fleet.Fact, 0) + for _, result := range results { + if result.err != nil { + coverage.Unreachable = append(coverage.Unreachable, result.name) + if isStale(now, lastSeen[result.name], adapter.settings.staleAfter) { + coverage.Stale = append(coverage.Stale, result.name) + } + continue + } + coverage.Reachable++ + facts = append(facts, result.facts...) + if !result.observedAt.IsZero() { + adapter.recordLastSeen(result.name, result.observedAt) + } else if isStale(now, lastSeen[result.name], adapter.settings.staleAfter) { + coverage.Stale = append(coverage.Stale, result.name) + } + } + + sort.Slice(facts, func(left, right int) bool { + return facts[left].Ref.String() < facts[right].Ref.String() + }) + if query.Limit > 0 && len(facts) > query.Limit { + facts = facts[:query.Limit] + } + if facts == nil { + facts = []fleet.Fact{} + } + sort.Strings(coverage.Unreachable) + sort.Strings(coverage.Stale) + return fleet.QueryResult{Facts: facts, Coverage: coverage}, nil +} + +type scopeQueryResult struct { + name string + facts []fleet.Fact + observedAt time.Time + err error +} + +func (adapter *Adapter) queryScope( + ctx context.Context, + name string, + client dynamic.Interface, + spec resourceSpec, + labelSelector string, + query fleet.Query, +) scopeQueryResult { + result := scopeQueryResult{name: name} + if client == nil { + result.err = ErrUnreachableScope + return result + } + if query.Selector.ResourceKind == "" { + return result + } + + resource := resourceInterface(client, spec, query.Selector.Namespace) + list, err := resource.List(ctx, metav1.ListOptions{LabelSelector: labelSelector}) + if err != nil { + if spec.kind == "Rollout" && apierrors.IsNotFound(err) { + return result + } + result.err = fmt.Errorf("list %s in %s: %w", spec.kind, name, err) + return result + } + + result.observedAt = adapter.settings.now().UTC() + if !wantsInventory(query.Kinds) { + result.facts = []fleet.Fact{} + return result + } + result.facts = make([]fleet.Fact, 0, len(list.Items)) + for _, object := range list.Items { + if query.Selector.NamePrefix != "" && !strings.HasPrefix(object.GetName(), query.Selector.NamePrefix) { + continue + } + if query.Selector.Image != "" && !objectUsesImage(object, query.Selector.Image) { + continue + } + evidence, err := evidenceFromObject(object, spec, name, result.observedAt) + if err != nil { + result.err = err + return result + } + result.facts = append(result.facts, fleet.Fact{Evidence: evidence, Workspace: fleet.LocalWorkspace}) + } + return result +} + +func evidenceFromObject( + object unstructured.Unstructured, + spec resourceSpec, + scope string, + observedAt time.Time, +) (fleet.Evidence, error) { + payload, err := json.Marshal(object.Object) + if err != nil { + return fleet.Evidence{}, fmt.Errorf("marshal %s/%s: %w", spec.kind, object.GetName(), err) + } + return fleet.Evidence{ + Ref: fleet.ResourceRef{ + SourceKind: Kind, + Scope: scope, + Kind: spec.kind, + Namespace: object.GetNamespace(), + Name: object.GetName(), + Attributes: map[string]string{"gvr": spec.gvr.String()}, + }, + Kind: fleet.FactInventory, + Observed: payload, + ObservedAt: observedAt, + Source: scope, + Provenance: fleet.Provenance{ + Adapter: Kind, + ProtocolV: protocolVersion, + NativeID: string(object.GetUID()), + }, + }, nil +} + +func lookupResource(kind string) (resourceSpec, bool) { + spec, ok := resourceSpecs[strings.ToLower(strings.TrimSpace(kind))] + return spec, ok +} + +func resourceInterface(client dynamic.Interface, spec resourceSpec, namespace string) dynamic.ResourceInterface { + resource := client.Resource(spec.gvr) + if spec.namespaced { + return resource.Namespace(namespace) + } + return resource +} + +func wantsInventory(kinds []fleet.FactKind) bool { + if len(kinds) == 0 { + return true + } + for _, kind := range kinds { + if kind == fleet.FactInventory { + return true + } + } + return false +} + +func objectUsesImage(object unstructured.Unstructured, image string) bool { + paths := [][]string{ + {"spec", "containers"}, + {"spec", "initContainers"}, + {"spec", "template", "spec", "containers"}, + {"spec", "template", "spec", "initContainers"}, + } + for _, path := range paths { + containers, found, err := unstructured.NestedSlice(object.Object, path...) + if err != nil || !found { + continue + } + for _, raw := range containers { + container, ok := raw.(map[string]any) + if !ok { + continue + } + value, ok := container["image"].(string) + if ok && strings.Contains(value, image) { + return true + } + } + } + return false +} + +func targetScopeNames(requested []string, scopes map[string]connector.Scope) []string { + set := make(map[string]struct{}) + if len(requested) == 0 { + for name := range scopes { + set[name] = struct{}{} + } + } else { + for _, name := range requested { + if name != "" { + set[name] = struct{}{} + } + } + } + result := make([]string, 0, len(set)) + for name := range set { + result = append(result, name) + } + sort.Strings(result) + return result +} + +func (adapter *Adapter) scopeClient(name string) (connector.Scope, dynamic.Interface, bool) { + adapter.mu.RLock() + defer adapter.mu.RUnlock() + scope, exists := adapter.scopes[name] + return cloneScope(scope), adapter.clients[name], exists +} + +func (adapter *Adapter) stateSnapshot() ( + map[string]connector.Scope, + map[string]dynamic.Interface, + map[string]time.Time, +) { + adapter.mu.RLock() + defer adapter.mu.RUnlock() + scopes := make(map[string]connector.Scope, len(adapter.scopes)) + for name, scope := range adapter.scopes { + scopes[name] = cloneScope(scope) + } + clients := make(map[string]dynamic.Interface, len(adapter.clients)) + for name, client := range adapter.clients { + clients[name] = client + } + lastSeen := make(map[string]time.Time, len(adapter.lastSeen)) + for name, observed := range adapter.lastSeen { + lastSeen[name] = observed + } + return scopes, clients, lastSeen +} + +func (adapter *Adapter) recordLastSeen(name string, observed time.Time) { + adapter.mu.Lock() + adapter.lastSeen[name] = observed + scope := adapter.scopes[name] + scope.ObservedAt = observed + scope.Reachable = true + adapter.scopes[name] = scope + adapter.mu.Unlock() +} + +func isStale(now, observed time.Time, threshold time.Duration) bool { + return !observed.IsZero() && now.Sub(observed) > threshold +} diff --git a/internal/connector/registry.go b/internal/connector/registry.go new file mode 100644 index 0000000..6966d9e --- /dev/null +++ b/internal/connector/registry.go @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: Apache-2.0 + +package connector + +import ( + "errors" + "fmt" + "reflect" + "sort" + "sync" +) + +// ErrNotRegistered reports a lookup for an unknown connector kind. +var ErrNotRegistered = errors.New("connector is not registered") + +// ErrCapability reports a lookup for a capability the connector did not opt into. +var ErrCapability = errors.New("connector capability is unavailable") + +// Factory constructs a configured connector for fail-safe registration. +type Factory func() (Connector, error) + +type registryEntry struct { + connector Connector + descriptor Descriptor + declared map[Capability]struct{} +} + +// Registry stores one canonical, capability-checked connector per kind. +type Registry struct { + mu sync.RWMutex + entries map[string]registryEntry +} + +// NewRegistry returns an empty connector registry. +func NewRegistry() *Registry { + return &Registry{entries: make(map[string]registryEntry)} +} + +// Register builds and validates a connector before atomically adding it. +func (registry *Registry) Register(factory Factory) error { + if factory == nil { + return fmt.Errorf("register connector: factory is nil") + } + + candidate, err := factory() + if err != nil { + return fmt.Errorf("register connector: construct: %w", err) + } + if connectorIsNil(candidate) { + return fmt.Errorf("register connector: factory returned nil") + } + + entry, err := validateConnector(candidate) + if err != nil { + return fmt.Errorf("register connector %q: %w", candidate.Kind(), err) + } + + registry.mu.Lock() + defer registry.mu.Unlock() + if _, exists := registry.entries[entry.descriptor.Kind]; exists { + return fmt.Errorf("register connector %q: kind already registered", entry.descriptor.Kind) + } + registry.entries[entry.descriptor.Kind] = entry + return nil +} + +// ByKind returns the canonical connector registered for kind. +func (registry *Registry) ByKind(kind string) (Connector, bool) { + registry.mu.RLock() + defer registry.mu.RUnlock() + entry, ok := registry.entries[kind] + return entry.connector, ok +} + +// WithCapability lists connectors that both declare and implement a capability. +func (registry *Registry) WithCapability(capability Capability) []Connector { + if !capability.Valid() { + return []Connector{} + } + + registry.mu.RLock() + entries := make([]registryEntry, 0, len(registry.entries)) + for _, entry := range registry.entries { + if _, declared := entry.declared[capability]; declared && implementsCapability(entry.connector, capability) { + entries = append(entries, entry) + } + } + registry.mu.RUnlock() + + sort.Slice(entries, func(left, right int) bool { + return entries[left].descriptor.Kind < entries[right].descriptor.Kind + }) + connectors := make([]Connector, 0, len(entries)) + for _, entry := range entries { + connectors = append(connectors, entry.connector) + } + return connectors +} + +// Descriptors returns deterministically ordered copies of registered metadata. +func (registry *Registry) Descriptors() []Descriptor { + registry.mu.RLock() + descriptors := make([]Descriptor, 0, len(registry.entries)) + for _, entry := range registry.entries { + descriptors = append(descriptors, cloneDescriptor(entry.descriptor)) + } + registry.mu.RUnlock() + + sort.Slice(descriptors, func(left, right int) bool { + return descriptors[left].Kind < descriptors[right].Kind + }) + return descriptors +} + +// ReaderFor returns a registered connector that declared read and implements Reader. +func (registry *Registry) ReaderFor(kind string) (Reader, error) { + entry, err := registry.entryFor(kind, CapRead, false) + if err != nil { + return nil, err + } + reader, ok := entry.connector.(Reader) + if !ok { + return nil, fmt.Errorf("%w: %s does not implement reader", ErrCapability, kind) + } + return reader, nil +} + +// DifferFor returns a registered connector that declared and implements diff. +func (registry *Registry) DifferFor(kind string) (Differ, error) { + entry, err := registry.entryFor(kind, CapDiff, false) + if err != nil { + return nil, err + } + differ, ok := entry.connector.(Differ) + if !ok { + return nil, fmt.Errorf("%w: %s does not implement diff", ErrCapability, kind) + } + return differ, nil +} + +// PlannerFor returns a typed-action connector that declared and implements plan. +func (registry *Registry) PlannerFor(kind string) (Planner, error) { + entry, err := registry.entryFor(kind, CapPlan, true) + if err != nil { + return nil, err + } + planner, ok := entry.connector.(Planner) + if !ok { + return nil, fmt.Errorf("%w: %s does not implement plan", ErrCapability, kind) + } + return planner, nil +} + +// ExecutorFor returns a typed-action connector that declared and implements execute. +func (registry *Registry) ExecutorFor(kind string) (Executor, error) { + entry, err := registry.entryFor(kind, CapExecute, true) + if err != nil { + return nil, err + } + executor, ok := entry.connector.(Executor) + if !ok { + return nil, fmt.Errorf("%w: %s does not implement execute", ErrCapability, kind) + } + return executor, nil +} + +// VerifierFor returns a typed-action connector that declared and implements verify. +func (registry *Registry) VerifierFor(kind string) (Verifier, error) { + entry, err := registry.entryFor(kind, CapVerify, true) + if err != nil { + return nil, err + } + verifier, ok := entry.connector.(Verifier) + if !ok { + return nil, fmt.Errorf("%w: %s does not implement verify", ErrCapability, kind) + } + return verifier, nil +} + +func (registry *Registry) entryFor(kind string, capability Capability, typedAction bool) (registryEntry, error) { + registry.mu.RLock() + entry, exists := registry.entries[kind] + registry.mu.RUnlock() + if !exists { + return registryEntry{}, fmt.Errorf("%w: %s", ErrNotRegistered, kind) + } + if _, declared := entry.declared[capability]; !declared { + return registryEntry{}, fmt.Errorf("%w: %s did not declare %s", ErrCapability, kind, capability) + } + if typedAction && entry.descriptor.ConnKind != KindTypedAction { + return registryEntry{}, fmt.Errorf("%w: %s is not a typed-action connector", ErrCapability, kind) + } + return entry, nil +} + +func validateConnector(candidate Connector) (registryEntry, error) { + descriptor := cloneDescriptor(candidate.Descriptor()) + if descriptor.Kind == "" || candidate.Kind() == "" { + return registryEntry{}, fmt.Errorf("kind must not be empty") + } + if descriptor.Kind != candidate.Kind() { + return registryEntry{}, fmt.Errorf("descriptor kind %q does not match connector kind %q", descriptor.Kind, candidate.Kind()) + } + if !descriptor.ConnKind.Valid() { + return registryEntry{}, fmt.Errorf("invalid connector kind %q", descriptor.ConnKind) + } + if descriptor.ProtocolV == "" { + return registryEntry{}, fmt.Errorf("protocol version must not be empty") + } + if descriptor.Owner == "" { + return registryEntry{}, fmt.Errorf("owner must not be empty") + } + + declared, err := capabilitySet(candidate.Capabilities()) + if err != nil { + return registryEntry{}, err + } + descriptorSet, err := capabilitySet(descriptor.Capabilities) + if err != nil { + return registryEntry{}, fmt.Errorf("descriptor: %w", err) + } + if !sameCapabilities(declared, descriptorSet) { + return registryEntry{}, fmt.Errorf("descriptor capabilities do not match connector declaration") + } + for capability := range declared { + if !implementsCapability(candidate, capability) { + return registryEntry{}, fmt.Errorf("declares %s without implementing its interface", capability) + } + } + + if descriptor.ConnKind == KindTypedAction { + if len(descriptor.Verbs) == 0 { + return registryEntry{}, fmt.Errorf("typed-action connector must declare at least one verb") + } + seen := make(map[string]struct{}, len(descriptor.Verbs)) + for _, verb := range descriptor.Verbs { + if !ValidVerb(verb) { + return registryEntry{}, fmt.Errorf("invalid action verb %q", verb) + } + if _, duplicate := seen[verb]; duplicate { + return registryEntry{}, fmt.Errorf("duplicate action verb %q", verb) + } + seen[verb] = struct{}{} + } + } else if len(descriptor.Verbs) != 0 { + return registryEntry{}, fmt.Errorf("non-action connector must not declare action verbs") + } + + return registryEntry{connector: candidate, descriptor: descriptor, declared: declared}, nil +} + +func capabilitySet(capabilities []Capability) (map[Capability]struct{}, error) { + set := make(map[Capability]struct{}, len(capabilities)) + for _, capability := range capabilities { + if !capability.Valid() { + return nil, fmt.Errorf("invalid capability %q", capability) + } + if _, duplicate := set[capability]; duplicate { + return nil, fmt.Errorf("duplicate capability %q", capability) + } + set[capability] = struct{}{} + } + return set, nil +} + +func sameCapabilities(left, right map[Capability]struct{}) bool { + if len(left) != len(right) { + return false + } + for capability := range left { + if _, exists := right[capability]; !exists { + return false + } + } + return true +} + +func implementsCapability(candidate Connector, capability Capability) bool { + switch capability { + case CapDiscover, CapRead, CapQuery: + _, ok := candidate.(Reader) + return ok + case CapDiff: + _, ok := candidate.(Differ) + return ok + case CapPlan: + _, ok := candidate.(Planner) + return ok + case CapExecute: + _, ok := candidate.(Executor) + return ok + case CapVerify: + _, ok := candidate.(Verifier) + return ok + default: + return false + } +} + +func connectorIsNil(candidate Connector) bool { + if candidate == nil { + return true + } + value := reflect.ValueOf(candidate) + switch value.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return value.IsNil() + default: + return false + } +} + +func cloneDescriptor(descriptor Descriptor) Descriptor { + descriptor.Capabilities = append([]Capability(nil), descriptor.Capabilities...) + descriptor.Verbs = append([]string(nil), descriptor.Verbs...) + return descriptor +} diff --git a/internal/connector/registry_test.go b/internal/connector/registry_test.go new file mode 100644 index 0000000..028c3cf --- /dev/null +++ b/internal/connector/registry_test.go @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: Apache-2.0 + +package connector + +import ( + "context" + "errors" + "testing" + + "github.com/ArdurAI/sith/internal/fleet" +) + +type testReader struct { + kind string + descriptor Descriptor + discovery Discovery + query fleet.QueryResult +} + +func (reader testReader) Kind() string { + return reader.kind +} + +func (reader testReader) Capabilities() []Capability { + return append([]Capability(nil), reader.descriptor.Capabilities...) +} + +func (reader testReader) Descriptor() Descriptor { + return cloneDescriptor(reader.descriptor) +} + +func (reader testReader) Discover(_ context.Context) (Discovery, error) { + return reader.discovery, nil +} + +func (testReader) Read(_ context.Context, ref fleet.ResourceRef) (fleet.Evidence, error) { + return fleet.Evidence{Ref: ref}, nil +} + +func (reader testReader) Query(_ context.Context, _ fleet.Query) (fleet.QueryResult, error) { + return reader.query, nil +} + +type identityOnlyConnector struct { + descriptor Descriptor +} + +func (connector identityOnlyConnector) Kind() string { + return connector.descriptor.Kind +} + +func (connector identityOnlyConnector) Capabilities() []Capability { + return append([]Capability(nil), connector.descriptor.Capabilities...) +} + +func (connector identityOnlyConnector) Descriptor() Descriptor { + return cloneDescriptor(connector.descriptor) +} + +type testExecutor struct { + descriptor Descriptor +} + +func (connector testExecutor) Kind() string { + return connector.descriptor.Kind +} + +func (connector testExecutor) Capabilities() []Capability { + return append([]Capability(nil), connector.descriptor.Capabilities...) +} + +func (connector testExecutor) Descriptor() Descriptor { + return cloneDescriptor(connector.descriptor) +} + +func (testExecutor) Execute(_ context.Context, plan ActionPlan) (ExecutionResult, error) { + return ExecutionResult{IntentID: plan.IntentID, Applied: true}, nil +} + +func TestRegistryRegisterAndLookupReader(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + reader := newTestReader("zeta") + if err := registry.Register(func() (Connector, error) { return reader, nil }); err != nil { + t.Fatalf("Register() error = %v", err) + } + + got, ok := registry.ByKind("zeta") + if !ok || got.Kind() != "zeta" { + t.Fatalf("ByKind() = %v/%t", got, ok) + } + if _, err := registry.ReaderFor("zeta"); err != nil { + t.Fatalf("ReaderFor() error = %v", err) + } + if _, err := registry.ExecutorFor("zeta"); !errors.Is(err, ErrCapability) { + t.Fatalf("ExecutorFor() error = %v, want ErrCapability", err) + } +} + +func TestRegistryRejectsInvalidConnectors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + connector Connector + }{ + {name: "unknown taxonomy", connector: identityOnlyConnector{descriptor: Descriptor{Kind: "bad", ConnKind: "other", ProtocolV: "1.0.0", Owner: "test"}}}, + {name: "declared but not implemented", connector: identityOnlyConnector{descriptor: Descriptor{Kind: "bad", ConnKind: KindReadAdapter, ProtocolV: "1.0.0", Owner: "test", Capabilities: []Capability{CapRead}}}}, + {name: "unknown capability", connector: identityOnlyConnector{descriptor: Descriptor{Kind: "bad", ConnKind: KindReadAdapter, ProtocolV: "1.0.0", Owner: "test", Capabilities: []Capability{"shell"}}}}, + {name: "read adapter with verbs", connector: identityOnlyConnector{descriptor: Descriptor{Kind: "bad", ConnKind: KindReadAdapter, ProtocolV: "1.0.0", Owner: "test", Verbs: []string{"gitops.open-pr"}}}}, + {name: "action without verbs", connector: testExecutor{descriptor: Descriptor{Kind: "bad", ConnKind: KindTypedAction, ProtocolV: "1.0.0", Owner: "test", Capabilities: []Capability{CapExecute}}}}, + {name: "action with unknown verb", connector: testExecutor{descriptor: Descriptor{Kind: "bad", ConnKind: KindTypedAction, ProtocolV: "1.0.0", Owner: "test", Capabilities: []Capability{CapExecute}, Verbs: []string{"shell.exec"}}}}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + registry := NewRegistry() + if err := registry.Register(func() (Connector, error) { return test.connector, nil }); err == nil { + t.Fatal("Register() error = nil, want rejection") + } + if len(registry.Descriptors()) != 0 { + t.Fatal("invalid connector was partially registered") + } + }) + } +} + +func TestRegistryRejectsDuplicateKind(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + reader := newTestReader("duplicate") + if err := registry.Register(func() (Connector, error) { return reader, nil }); err != nil { + t.Fatalf("first Register() error = %v", err) + } + if err := registry.Register(func() (Connector, error) { return reader, nil }); err == nil { + t.Fatal("second Register() error = nil") + } +} + +func TestRegistryWithCapabilityIsDeterministic(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + for _, kind := range []string{"zeta", "alpha"} { + reader := newTestReader(kind) + if err := registry.Register(func() (Connector, error) { return reader, nil }); err != nil { + t.Fatalf("Register(%s) error = %v", kind, err) + } + } + + got := registry.WithCapability(CapQuery) + if len(got) != 2 || got[0].Kind() != "alpha" || got[1].Kind() != "zeta" { + t.Fatalf("WithCapability() = %#v, want alpha then zeta", got) + } + if got := registry.WithCapability("unknown"); got == nil || len(got) != 0 { + t.Fatalf("unknown WithCapability() = %#v, want allocated empty slice", got) + } +} + +func TestRegistryExecutorRequiresTypedAction(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + executor := testExecutor{descriptor: Descriptor{ + Kind: "argocd", + ConnKind: KindTypedAction, + ProtocolV: "1.0.0", + Owner: "test", + Capabilities: []Capability{CapExecute}, + Verbs: []string{"argocd.sync"}, + }} + if err := registry.Register(func() (Connector, error) { return executor, nil }); err != nil { + t.Fatalf("Register() error = %v", err) + } + + got, err := registry.ExecutorFor("argocd") + if err != nil { + t.Fatalf("ExecutorFor() error = %v", err) + } + result, err := got.Execute(context.Background(), ActionPlan{IntentID: "intent-1"}) + if err != nil || !result.Applied { + t.Fatalf("Execute() = %#v, %v", result, err) + } +} + +func TestRegistryFactoryFailuresAreAtomic(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + wantErr := errors.New("construction failed") + if err := registry.Register(func() (Connector, error) { return nil, wantErr }); !errors.Is(err, wantErr) { + t.Fatalf("Register() error = %v, want wrapped construction error", err) + } + if err := registry.Register(nil); err == nil { + t.Fatal("Register(nil) error = nil") + } + if len(registry.Descriptors()) != 0 { + t.Fatal("failed factory modified registry") + } +} + +func TestValidVerb(t *testing.T) { + t.Parallel() + + if !ValidVerb("gitops.open-pr") || ValidVerb("shell.exec") { + t.Fatal("ValidVerb() does not enforce the closed vocabulary") + } +} + +func newTestReader(kind string) testReader { + capabilities := []Capability{CapDiscover, CapRead, CapQuery} + return testReader{ + kind: kind, + descriptor: Descriptor{ + Kind: kind, + ConnKind: KindReadAdapter, + ProtocolV: "1.0.0", + Owner: "test", + Capabilities: capabilities, + }, + } +} diff --git a/internal/connector/source.go b/internal/connector/source.go new file mode 100644 index 0000000..46cadb0 --- /dev/null +++ b/internal/connector/source.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 + +package connector + +import ( + "context" + "fmt" + "sort" + + "github.com/ArdurAI/sith/internal/fleet" +) + +var _ fleet.Source = readerSource{} + +// AsSource adapts a Reader to the stable fleet snapshot seam used by the CLI. +func AsSource(reader Reader) fleet.Source { + return readerSource{reader: reader} +} + +type readerSource struct { + reader Reader +} + +func (source readerSource) Kind() string { + if source.reader == nil { + return "invalid" + } + return source.reader.Kind() +} + +func (source readerSource) Fleet(ctx context.Context) (fleet.FleetResult, error) { + if source.reader == nil { + return fleet.FleetResult{}, fmt.Errorf("adapt reader: reader is nil") + } + + discovery, err := source.reader.Discover(ctx) + if err != nil { + return fleet.FleetResult{}, fmt.Errorf("discover %s scopes: %w", source.reader.Kind(), err) + } + queryResult, err := source.reader.Query(ctx, fleet.Query{Kinds: []fleet.FactKind{fleet.FactInventory, fleet.FactHealth}}) + if err != nil { + return fleet.FleetResult{}, fmt.Errorf("query %s snapshot: %w", source.reader.Kind(), err) + } + + clusters := make([]fleet.Cluster, 0, len(discovery.Scopes)+len(discovery.Unreachable)) + seen := make(map[string]struct{}, len(discovery.Scopes)) + for _, scope := range discovery.Scopes { + clusters = append(clusters, fleet.Cluster{ + Name: scope.Name, + Context: scope.Name, + SourceKind: source.reader.Kind(), + Reachable: scope.Reachable, + ObservedAt: scope.ObservedAt, + }) + seen[scope.Name] = struct{}{} + } + for _, name := range discovery.Unreachable { + if _, exists := seen[name]; exists { + continue + } + clusters = append(clusters, fleet.Cluster{ + Name: name, + Context: name, + SourceKind: source.reader.Kind(), + }) + } + sort.Slice(clusters, func(left, right int) bool { + return clusters[left].Name < clusters[right].Name + }) + + coverage := queryResult.Coverage + if coverage.Requested == 0 && len(clusters) != 0 { + coverage.Requested = len(clusters) + for _, cluster := range clusters { + if cluster.Reachable { + coverage.Reachable++ + } else { + coverage.Unreachable = append(coverage.Unreachable, cluster.Name) + } + } + } + coverage.Unreachable = sortedUnique(coverage.Unreachable) + coverage.Stale = sortedUnique(coverage.Stale) + + return fleet.FleetResult{Clusters: clusters, Coverage: coverage}, nil +} + +func sortedUnique(values []string) []string { + if len(values) == 0 { + return nil + } + set := make(map[string]struct{}, len(values)) + for _, value := range values { + if value != "" { + set[value] = struct{}{} + } + } + result := make([]string, 0, len(set)) + for value := range set { + result = append(result, value) + } + sort.Strings(result) + return result +} diff --git a/internal/connector/source_test.go b/internal/connector/source_test.go new file mode 100644 index 0000000..134db90 --- /dev/null +++ b/internal/connector/source_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +package connector + +import ( + "context" + "testing" + "time" +) + +func TestAsSourcePreservesCoverageAndScopes(t *testing.T) { + t.Parallel() + + observed := time.Date(2026, 7, 10, 19, 0, 0, 0, time.UTC) + reader := newTestReader("memory") + reader.discovery = Discovery{ + Scopes: []Scope{ + {Name: "prod", Reachable: true, ObservedAt: observed}, + {Name: "lab", Reachable: false}, + }, + Unreachable: []string{"lab"}, + } + reader.query.Coverage.Requested = 2 + reader.query.Coverage.Reachable = 1 + reader.query.Coverage.Unreachable = []string{"lab", "lab"} + + source := AsSource(reader) + result, err := source.Fleet(context.Background()) + if err != nil { + t.Fatalf("Fleet() error = %v", err) + } + if source.Kind() != "memory" { + t.Fatalf("Kind() = %q", source.Kind()) + } + if len(result.Clusters) != 2 || result.Clusters[0].Name != "lab" || result.Clusters[1].Name != "prod" { + t.Fatalf("Clusters = %#v", result.Clusters) + } + if result.Clusters[1].ObservedAt != observed || !result.Clusters[1].Reachable { + t.Fatalf("prod cluster = %#v", result.Clusters[1]) + } + if len(result.Coverage.Unreachable) != 1 || result.Coverage.Unreachable[0] != "lab" { + t.Fatalf("Coverage = %#v", result.Coverage) + } +} + +func TestAsSourceFallsBackToDiscoveryCoverage(t *testing.T) { + t.Parallel() + + reader := newTestReader("memory") + reader.discovery = Discovery{ + Scopes: []Scope{{Name: "prod", Reachable: true}}, + Unreachable: []string{"missing"}, + } + + result, err := AsSource(reader).Fleet(context.Background()) + if err != nil { + t.Fatalf("Fleet() error = %v", err) + } + if result.Coverage.Requested != 2 || result.Coverage.Reachable != 1 { + t.Fatalf("Coverage = %#v", result.Coverage) + } +} diff --git a/internal/fleet/model.go b/internal/fleet/model.go index b1b9552..d1a6be1 100644 --- a/internal/fleet/model.go +++ b/internal/fleet/model.go @@ -27,4 +27,10 @@ type Coverage struct { Requested int `json:"requested"` Reachable int `json:"reachable"` Unreachable []string `json:"unreachable,omitempty"` + Stale []string `json:"stale,omitempty"` +} + +// Complete reports whether every requested scope answered with fresh data. +func (c Coverage) Complete() bool { + return c.Requested == c.Reachable && len(c.Stale) == 0 } diff --git a/internal/fleet/resource.go b/internal/fleet/resource.go new file mode 100644 index 0000000..7906593 --- /dev/null +++ b/internal/fleet/resource.go @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: Apache-2.0 + +package fleet + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// LocalWorkspace is the implicit single-user workspace used by local mode. +const LocalWorkspace = "local" + +// ResourceRef is a source-abstract address for one fleet resource. +type ResourceRef struct { + SourceKind string `json:"source_kind"` + Scope string `json:"scope"` + Kind string `json:"kind"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name"` + Attributes map[string]string `json:"attributes,omitempty"` +} + +// Equal compares source-abstract identity while ignoring adapter-specific attributes. +func (r ResourceRef) Equal(other ResourceRef) bool { + return r.SourceKind == other.SourceKind && + r.Scope == other.Scope && + r.Kind == other.Kind && + r.Namespace == other.Namespace && + r.Name == other.Name +} + +// String returns a stable address suitable for logs and audit records. +func (r ResourceRef) String() string { + parts := []string{r.SourceKind + ":" + r.Scope, r.Kind} + if r.Namespace != "" { + parts = append(parts, r.Namespace) + } + parts = append(parts, r.Name) + return strings.Join(parts, "/") +} + +// FactKind is the closed taxonomy of normalized fleet observations. +type FactKind string + +// Supported fact kinds. +const ( + FactInventory FactKind = "inventory" + FactHealth FactKind = "health" + FactAlert FactKind = "alert" + FactDrift FactKind = "drift" + FactCVE FactKind = "cve" + FactCost FactKind = "cost" +) + +// Valid reports whether the fact kind belongs to the closed taxonomy. +func (kind FactKind) Valid() bool { + switch kind { + case FactInventory, FactHealth, FactAlert, FactDrift, FactCVE, FactCost: + return true + default: + return false + } +} + +// Evidence is observed state plus source and collection provenance. +type Evidence struct { + Ref ResourceRef `json:"ref"` + Kind FactKind `json:"kind"` + Observed json.RawMessage `json:"observed"` + ObservedAt time.Time `json:"observed_at"` + Source string `json:"source"` + Provenance Provenance `json:"provenance"` +} + +// Provenance identifies how to trace an observation back to its native source. +type Provenance struct { + Adapter string `json:"adapter"` + ProtocolV string `json:"protocol_version"` + NativeID string `json:"native_id,omitempty"` + DeepLink string `json:"deep_link,omitempty"` + Collector string `json:"collector,omitempty"` +} + +// Fact is evidence stamped with workspace and derived freshness. +type Fact struct { + Evidence + Workspace string `json:"workspace"` + Stale bool `json:"stale"` + StaleFor string `json:"stale_for,omitempty"` +} + +// Query expresses a typed selection over normalized fleet facts. +type Query struct { + Kinds []FactKind `json:"kinds,omitempty"` + Scopes []string `json:"scopes,omitempty"` + Selector Selector `json:"selector,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// Validate rejects unknown or unsafe query values. +func (query Query) Validate() error { + if query.Limit < 0 { + return fmt.Errorf("query limit must not be negative") + } + for _, kind := range query.Kinds { + if !kind.Valid() { + return fmt.Errorf("invalid fact kind %q", kind) + } + } + for key := range query.Selector.Labels { + if strings.TrimSpace(key) == "" { + return fmt.Errorf("label selector key must not be empty") + } + } + if query.Selector.Health != "" { + switch query.Selector.Health { + case "Healthy", "Degraded", "Progressing", "Unknown": + default: + return fmt.Errorf("invalid health selector %q", query.Selector.Health) + } + } + + return nil +} + +// Selector is the fail-safe, typed predicate set supported by fleet queries. +type Selector struct { + ResourceKind string `json:"resource_kind,omitempty"` + Namespace string `json:"namespace,omitempty"` + NamePrefix string `json:"name_prefix,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Health string `json:"health,omitempty"` + Image string `json:"image,omitempty"` + CVE string `json:"cve,omitempty"` +} + +// QueryResult contains normalized facts and honest scope coverage. +type QueryResult struct { + Facts []Fact `json:"facts"` + Coverage Coverage `json:"coverage"` +} + +// Diff is a structured desired-versus-observed result. +type Diff struct { + Ref ResourceRef `json:"ref"` + Drifted bool `json:"drifted"` + Hunks []DiffHunk `json:"hunks,omitempty"` +} + +// DiffHunk is one field-level desired-versus-observed change. +type DiffHunk struct { + Path string `json:"path"` + Observed string `json:"observed"` + Desired string `json:"desired"` +} + +// Graph is the source-abstract operational graph assembled from facts. +type Graph struct { + Nodes []Node `json:"nodes"` + Edges []Edge `json:"edges"` +} + +// Node is one addressed resource and its latest fact. +type Node struct { + Ref ResourceRef `json:"ref"` + Fact Fact `json:"fact"` +} + +// Relation is the closed taxonomy of cross-resource graph edges. +type Relation string + +// Supported graph relations. +const ( + RelOwns Relation = "owns" + RelRoutesTo Relation = "routes_to" + RelBackedBy Relation = "backed_by" + RelDeployedFrom Relation = "deployed_from" + RelRunsImage Relation = "runs_image" + RelAlertsOn Relation = "alerts_on" + RelCostsFor Relation = "costs_for" +) + +// Edge is one typed relationship between fleet resources. +type Edge struct { + From ResourceRef `json:"from"` + To ResourceRef `json:"to"` + Rel Relation `json:"rel"` +} diff --git a/internal/fleet/resource_test.go b/internal/fleet/resource_test.go new file mode 100644 index 0000000..c298026 --- /dev/null +++ b/internal/fleet/resource_test.go @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 + +package fleet + +import "testing" + +func TestResourceRefEqualIgnoresAttributes(t *testing.T) { + t.Parallel() + + left := ResourceRef{ + SourceKind: "local-kubeconfig", + Scope: "prod", + Kind: "Pod", + Namespace: "payments", + Name: "api-123", + Attributes: map[string]string{"uid": "one"}, + } + right := left + right.Attributes = map[string]string{"uid": "two"} + if !left.Equal(right) { + t.Fatal("Equal() = false for identical source-abstract identity") + } + + right.Name = "api-456" + if left.Equal(right) { + t.Fatal("Equal() = true for different resource names") + } +} + +func TestResourceRefString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ref ResourceRef + want string + }{ + { + name: "namespaced", + ref: ResourceRef{SourceKind: "local-kubeconfig", Scope: "prod", Kind: "Pod", Namespace: "payments", Name: "api"}, + want: "local-kubeconfig:prod/Pod/payments/api", + }, + { + name: "cluster scoped", + ref: ResourceRef{SourceKind: "local-kubeconfig", Scope: "prod", Kind: "Node", Name: "worker-1"}, + want: "local-kubeconfig:prod/Node/worker-1", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := test.ref.String(); got != test.want { + t.Fatalf("String() = %q, want %q", got, test.want) + } + }) + } +} + +func TestFactKindValid(t *testing.T) { + t.Parallel() + + for _, kind := range []FactKind{FactInventory, FactHealth, FactAlert, FactDrift, FactCVE, FactCost} { + if !kind.Valid() { + t.Errorf("Valid() = false for %q", kind) + } + } + if FactKind("unknown").Valid() { + t.Fatal("Valid() = true for unknown fact kind") + } +} + +func TestQueryValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query Query + wantErr bool + }{ + {name: "valid", query: Query{Kinds: []FactKind{FactInventory}, Selector: Selector{Health: "Healthy"}, Limit: 10}}, + {name: "negative limit", query: Query{Limit: -1}, wantErr: true}, + {name: "unknown fact", query: Query{Kinds: []FactKind{"mystery"}}, wantErr: true}, + {name: "empty label", query: Query{Selector: Selector{Labels: map[string]string{"": "x"}}}, wantErr: true}, + {name: "unknown health", query: Query{Selector: Selector{Health: "Fine"}}, wantErr: true}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + err := test.query.Validate() + if (err != nil) != test.wantErr { + t.Fatalf("Validate() error = %v, wantErr %t", err, test.wantErr) + } + }) + } +} + +func TestCoverageComplete(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + coverage Coverage + want bool + }{ + {name: "complete", coverage: Coverage{Requested: 2, Reachable: 2}, want: true}, + {name: "unreachable", coverage: Coverage{Requested: 2, Reachable: 1}}, + {name: "stale", coverage: Coverage{Requested: 2, Reachable: 2, Stale: []string{"prod"}}}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := test.coverage.Complete(); got != test.want { + t.Fatalf("Complete() = %t, want %t", got, test.want) + } + }) + } +} diff --git a/sessions/2026-07-10-slice-1-source-adapter.md b/sessions/2026-07-10-slice-1-source-adapter.md new file mode 100644 index 0000000..5d75ba0 --- /dev/null +++ b/sessions/2026-07-10-slice-1-source-adapter.md @@ -0,0 +1,65 @@ +# Session — 2026-07-10 — slice-1-source-adapter + +**Builder:** Gnani Rahul · **Model/effort:** engineering, max · **Branch:** feat/fleet-source-adapter +**Slice(s):** Slice 1 / #38 + #32 · **Status:** done + +--- + +[G] Goal: Implement the source-abstract fleet model, seven-verb connector contract, local-kubeconfig +adapter, independent fan-out, and a real two-kind-cluster proof for Slice 1. +[S] Scope: additive `internal/fleet` types, `internal/connector`, the kubeconfig read adapter, +`fleet.Source` bridge, the one CLI injection point, unit tests, and kind e2e. Cache-first TUI, +per-pod operations, web UI, MCP, keychain, OCM transport, and governed writes are out of scope. +[A] Action: Merged Slice 0 and authoritative specification PRs into `dev`, promoted the tested +foundation to `main` through release PR #51, and branched `feat/fleet-source-adapter` from tested +`dev` tip `a9bf340`. +[A] Action: Verified client-go v0.36.2 as the current upstream module and kind v0.32.0 with the +digest-pinned Kubernetes v1.36.1 node image. ExecCredential v1 behavior remains delegated to +client-go so plugins execute locally and tokens are never persisted by Sith. +[A] Action: Added the source-abstract resource/fact/query/diff/graph model, additive stale coverage, +the seven capability interfaces, closed connector taxonomy/action vocabulary, atomic registry, and +the `connector.Reader` to `fleet.Source` bridge. +[T] Test: Race-enabled fleet/connector unit tests and the strict linter pass. Tests prove identity +equality, fail-safe query validation, capability declaration+implementation checks, atomic invalid +registration, deterministic lookup, typed-action isolation, and coverage-preserving source parity. +[C] Checkpoint #1: 7ad0759 — additive fleet and connector contract; next: local-kubeconfig +adapter and client-go fan-out. +[A] Action: Current client-go v0.36.2 requires Go 1.26, so raised the module and CI toolchain from +Go 1.25 to the supported Go 1.26 line instead of pinning an older Kubernetes client. +[T] Test: Rebuilt golangci-lint v2.12.2 with Go 1.26.5; the complete `make ci` gate passes on the +new toolchain with no code or output changes. +[C] Checkpoint #2: a53d262 — adopt the supported Go 1.26 toolchain required by current +client-go; next: implement the adapter. +[A] Action: Implemented the read-only local-kubeconfig adapter with independent bounded context +probes, dynamic clients, typed inventory reads/queries, explicit partial coverage, and preserved +last-seen timestamps when a previously reachable context becomes unavailable. +[T] Test: Adapter tests exercise concurrent success/failure, stale observation preservation, +typed label/name/image selectors, source-stamped evidence, unknown/unreachable reads, and an actual +ExecCredential v1 subprocess authenticated request to a TLS test API. Focused race tests, lint, +and 81.5% statement coverage pass. +[C] Checkpoint #3: bad1a1f — local-kubeconfig discovery/read/query adapter; next: bridge the +adapter into `sith clusters` and validate the real CLI path. +[A] Action: Replaced the Slice-0 stub at the single CLI injection point with +`connector.AsSource(kubeconfig.Default())`; default construction follows client-go's standard +`KUBECONFIG` path-list and `~/.kube/config` resolution without doing startup network I/O. +[A] Action: Updated the public README from the Slice-0 stub behavior to the real local-fleet +discovery and credential-locality contract. +[C] Checkpoint #4: 87053ca — production CLI bridge; next: prove two reachable kind clusters +plus one unreachable context through the built binary. +[A] Action: Added a hermetic real-cluster gate that creates two uniquely named kind clusters from +the digest-pinned Kubernetes v1.36.1 node image, merges their kubeconfigs with one deliberately +dead context, and cleans up only the clusters it created. +[T] Test: The gate asserts adapter discovery, a real namespace query returning source-stamped +facts from both API servers, honest 2/3 partial coverage, and the built `sith clusters --output +json` process over the same merged kubeconfig. CI installs pinned kind v0.32.0 before running it. +[R] Review: Red-team analysis added hard wall-clock isolation around client-go operations because +its exec authenticator does not itself bind helper-process lifetime to request context, rejected +invalid references/selectors before credential work, and made partial kind cleanup observable. +[R] Review: govulncheck v1.6.0 found two reachable `x/net` call-path vulnerabilities inherited +through client-go. Raised `x/net` to the fixed v0.55.0 floor and added a pinned CI/local scan; +the follow-up scan reports no reachable vulnerabilities. +[C] Checkpoint #5: this commit — reviewed real two-cluster fan-out gate; next: publish and merge. + +--- + +**Session close:** implementation and review complete; publication pending · **Open questions touched:** none diff --git a/tests/e2e/kind_fanout_test.go b/tests/e2e/kind_fanout_test.go new file mode 100644 index 0000000..54ed6ef --- /dev/null +++ b/tests/e2e/kind_fanout_test.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: Apache-2.0 +//go:build e2e && kind + +package e2e_test + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/ArdurAI/sith/internal/connector/kubeconfig" + "github.com/ArdurAI/sith/internal/fleet" +) + +const defaultKindNodeImage = "kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + +func TestKindFleetFanout(t *testing.T) { + kindBinary := environmentOr("KIND_BIN", "kind") + if _, err := exec.LookPath(kindBinary); err != nil { + t.Fatalf("find kind binary %q: %v", kindBinary, err) + } + if _, err := exec.LookPath("docker"); err != nil { + t.Fatalf("find docker: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute) + defer cancel() + version := runCommand(ctx, t, "", kindBinary, "version") + if !strings.Contains(version, "v0.32.0") { + t.Fatalf("kind version = %q, want v0.32.0", version) + } + + suffix := fmt.Sprintf("%d", time.Now().UnixNano()) + clusterNames := []string{"sith-e2e-a-" + suffix, "sith-e2e-b-" + suffix} + image := environmentOr("KIND_NODE_IMAGE", defaultKindNodeImage) + created := make([]string, 0, len(clusterNames)) + t.Cleanup(func() { + for _, name := range created { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Minute) + command := exec.CommandContext(cleanupCtx, kindBinary, "delete", "cluster", "--name", name) + output, err := command.CombinedOutput() + cleanupCancel() + if err != nil { + t.Errorf("delete kind cluster %s: %v\n%s", name, err, output) + } + } + }) + + for _, name := range clusterNames { + created = append(created, name) + runCommand(ctx, t, "", kindBinary, "create", "cluster", "--name", name, "--image", image, "--wait", "180s") + } + + kubeconfigPath := mergedKindKubeconfig(ctx, t, kindBinary, clusterNames) + adapter, err := kubeconfig.New( + kubeconfig.WithExplicitPath(kubeconfigPath), + kubeconfig.WithProbeTimeout(5*time.Second), + kubeconfig.WithRequestTimeout(15*time.Second), + ) + if err != nil { + t.Fatalf("construct kubeconfig adapter: %v", err) + } + + discovery, err := adapter.Discover(ctx) + if err != nil { + t.Fatalf("discover real kind contexts: %v", err) + } + deadContext := "kind-sith-e2e-unreachable" + if len(discovery.Scopes) != 3 || !slices.Equal(discovery.Unreachable, []string{deadContext}) { + t.Fatalf("discovery = %#v, want two reachable kind contexts and %q unreachable", discovery, deadContext) + } + + result, err := adapter.Query(ctx, fleet.Query{ + Kinds: []fleet.FactKind{fleet.FactInventory}, + Selector: fleet.Selector{ + ResourceKind: "Namespace", + NamePrefix: "kube-", + }, + }) + if err != nil { + t.Fatalf("query namespaces across kind contexts: %v", err) + } + if result.Coverage.Requested != 3 || result.Coverage.Reachable != 2 || + !slices.Equal(result.Coverage.Unreachable, []string{deadContext}) { + t.Fatalf("query coverage = %#v, want two of three reachable", result.Coverage) + } + liveScopes := map[string]bool{ + "kind-" + clusterNames[0]: false, + "kind-" + clusterNames[1]: false, + } + for _, fact := range result.Facts { + if fact.Ref.Kind == "Namespace" && strings.HasPrefix(fact.Ref.Name, "kube-") { + liveScopes[fact.Ref.Scope] = true + } + } + for scope, seen := range liveScopes { + if !seen { + t.Errorf("query did not return a source-stamped namespace from %s", scope) + } + } + + root := repositoryRoot(t) + binary := filepath.Join(t.TempDir(), "sith") + runCommand(ctx, t, root, "go", "build", "-trimpath", "-o", binary, "./cmd/sith") + command := exec.CommandContext(ctx, binary, "clusters", "--output", "json") + command.Env = append(os.Environ(), "KUBECONFIG="+kubeconfigPath, "XDG_CONFIG_HOME="+t.TempDir()) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("run sith clusters against kind: %v\n%s", err, output) + } + var fleetResult fleet.FleetResult + if err := json.Unmarshal(output, &fleetResult); err != nil { + t.Fatalf("decode sith clusters output %q: %v", output, err) + } + if len(fleetResult.Clusters) != 3 || fleetResult.Coverage.Requested != 3 || + fleetResult.Coverage.Reachable != 2 || !slices.Equal(fleetResult.Coverage.Unreachable, []string{deadContext}) { + t.Fatalf("sith clusters = %#v, want two live and one unreachable context", fleetResult) + } +} + +func mergedKindKubeconfig(ctx context.Context, t *testing.T, kindBinary string, clusters []string) string { + t.Helper() + merged := clientcmdapi.NewConfig() + for _, cluster := range clusters { + data := runCommandBytes(ctx, t, "", kindBinary, "get", "kubeconfig", "--name", cluster) + config, err := clientcmd.Load(data) + if err != nil { + t.Fatalf("decode kind kubeconfig for %s: %v", cluster, err) + } + mergeConfigMaps(merged, config) + if merged.CurrentContext == "" { + merged.CurrentContext = config.CurrentContext + } + } + + const deadContext = "kind-sith-e2e-unreachable" + merged.Clusters[deadContext] = &clientcmdapi.Cluster{Server: "https://127.0.0.1:1"} + merged.AuthInfos[deadContext] = &clientcmdapi.AuthInfo{} + merged.Contexts[deadContext] = &clientcmdapi.Context{Cluster: deadContext, AuthInfo: deadContext} + + path := filepath.Join(t.TempDir(), "kubeconfig") + if err := clientcmd.WriteToFile(*merged, path); err != nil { + t.Fatalf("write merged kind kubeconfig: %v", err) + } + return path +} + +func mergeConfigMaps(destination, source *clientcmdapi.Config) { + for name, cluster := range source.Clusters { + destination.Clusters[name] = cluster + } + for name, authInfo := range source.AuthInfos { + destination.AuthInfos[name] = authInfo + } + for name, contextConfig := range source.Contexts { + destination.Contexts[name] = contextConfig + } +} + +func environmentOr(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} + +func runCommand(ctx context.Context, t *testing.T, directory, name string, args ...string) string { + t.Helper() + return string(runCommandBytes(ctx, t, directory, name, args...)) +} + +func runCommandBytes(ctx context.Context, t *testing.T, directory, name string, args ...string) []byte { + t.Helper() + command := exec.CommandContext(ctx, name, args...) + command.Dir = directory + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("run %s %s: %v\n%s", name, strings.Join(args, " "), err, output) + } + return output +} diff --git a/tests/e2e/smoke_test.go b/tests/e2e/smoke_test.go index 1f198e7..cd5a4c8 100644 --- a/tests/e2e/smoke_test.go +++ b/tests/e2e/smoke_test.go @@ -19,6 +19,10 @@ import ( func TestBinarySmoke(t *testing.T) { root := repositoryRoot(t) binary := filepath.Join(t.TempDir(), "sith") + kubeconfig := filepath.Join(t.TempDir(), "kubeconfig") + if err := os.WriteFile(kubeconfig, []byte("apiVersion: v1\nkind: Config\n"), 0o600); err != nil { + t.Fatalf("write empty kubeconfig: %v", err) + } ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() @@ -36,7 +40,7 @@ func TestBinarySmoke(t *testing.T) { }{ {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 text", args: []string{"clusters"}, contains: "No clusters found (source: local-kubeconfig)."}, {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+"}, @@ -48,7 +52,7 @@ func TestBinarySmoke(t *testing.T) { 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()) + command.Env = append(os.Environ(), "XDG_CONFIG_HOME="+t.TempDir(), "KUBECONFIG="+kubeconfig) output, err := command.CombinedOutput() if err != nil { t.Fatalf("run %v: %v\n%s", test.args, err, output)