diff --git a/.gitignore b/.gitignore index 5481b22..ec1a953 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,6 @@ coverage.* node_modules/ dist/ build/ +cmd/sith-desktop/frontend/wailsjs/ .next/ coverage/ diff --git a/Makefile b/Makefile index a1494af..b00ab5f 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,11 @@ GOVULNCHECK ?= govulncheck KIND ?= kind HELM ?= helm GORELEASER ?= goreleaser +WAILS ?= wails +WAILS_VERSION ?= v2.12.0 +CODESIGN ?= codesign +PLISTBUDDY ?= /usr/libexec/PlistBuddy +LIPO ?= lipo DOCKER ?= docker KUBECTL ?= kubectl OCM_SCRATCH_ROOT ?= $(shell python3 -c 'import os; print(os.path.join(os.path.realpath(os.environ.get("TMPDIR", "/tmp")), "sith-m0-{}".format(os.getuid()), "lab"))') @@ -29,7 +34,7 @@ LDFLAGS := -s -w \ -X $(PKG)/internal/buildinfo.Commit=$(COMMIT) \ -X $(PKG)/internal/buildinfo.Date=$(DATE) -.PHONY: all build test test-scripts perf e2e e2e-helm e2e-oci e2e-kind e2e-ocm e2e-postgres e2e-isolation lint vuln fmt fmt-check vet tidy clean run ci release-check help +.PHONY: all build desktop-build test test-scripts perf e2e e2e-helm e2e-oci e2e-kind e2e-ocm e2e-postgres e2e-isolation lint vuln fmt fmt-check vet tidy clean run ci release-check help all: build @@ -37,6 +42,19 @@ build: ## Build the sith binary into bin/ @mkdir -p $(BIN_DIR) go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN_DIR)/$(BINARY) $(CMD) +desktop-build: ## Build the ad-hoc-signed macOS arm64 Sith.app development bundle + @command -v "$(WAILS)" >/dev/null || { echo "Wails $(WAILS_VERSION) is required" >&2; exit 1; } + @"$(WAILS)" version | grep -q '$(WAILS_VERSION)' || { echo "Wails $(WAILS_VERSION) is required" >&2; exit 1; } + cd cmd/sith-desktop && "$(WAILS)" build -clean -m -nosyncgomod -s -trimpath -platform darwin/arm64 + @set -euo pipefail; \ + app='cmd/sith-desktop/build/bin/Sith.app'; \ + test -d "$$app"; \ + "$(LIPO)" -archs "$$app/Contents/MacOS/Sith" | grep -qx 'arm64'; \ + "$(PLISTBUDDY)" -c 'Set :CFBundleIdentifier com.ardurai.sith' "$$app/Contents/Info.plist"; \ + "$(CODESIGN)" --force --sign - "$$app"; \ + "$(CODESIGN)" --verify --strict "$$app"; \ + plutil -extract CFBundleIdentifier raw -o - "$$app/Contents/Info.plist" | grep -qx 'com.ardurai.sith' + test: ## Run unit tests with the race detector and report coverage go test -race -count=1 -coverprofile=coverage.out ./... diff --git a/README.md b/README.md index e4e370c..84bbdbd 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ make build ./bin/sith port-forward service/api --context kind-dev -n apps :http ./bin/sith edit configmap/api-settings --context kind-dev -n apps ./bin/sith ui # loopback-only embedded fleet IDE +./bin/sith ui --kubeconfig-dir "$HOME/kubeconfigs" # import a folder of kubeconfig files for this UI session +./bin/sith desktop # native macOS window for the same local fleet IDE ./bin/sith serve --mcp # loopback-only MCP read server ./bin/sith serve --mcp --require-token ``` @@ -332,7 +334,16 @@ component dependency enters the binary. `sith ui` serves a build-free frontend embedded in the same Go binary. It binds to `127.0.0.1` on an available port by default; `--address` accepts loopback addresses only and -`--no-open` suppresses browser launch. The browser renders the same cache, lenses, ordering, +`--no-open` suppresses browser launch. `--kubeconfig-dir ` imports a bounded recursive +set of regular kubeconfig files for that UI session. It does not replace the standard +`KUBECONFIG`/`~/.kube/config` mode, write or persist a config, or follow directory symlinks. The +supplied root must be an existing real directory; a file, symlink, or missing root fails before the +local listener starts. Invalid, oversized, unreadable, or symlinked entries beneath a valid root +are skipped with safe warnings that do not expose kubeconfig contents or an absolute local path. +Each imported source is labeled by its relative filename; contexts with the same name remain +isolated, and selecting a source in the context rail filters to its contexts. The import is limited +to 128 traversed filesystem entries (including ignored symlinks and directories), 4 MiB per regular +kubeconfig file, and eight nested directory levels. The browser renders the same cache, lenses, ordering, coverage, search/correlation grammar, and per-resource operations as the CLI/TUI. Its local HTTP boundary requires an exact Host/Origin and a per-process capability header, uses a restrictive Content Security Policy, and loads no remote assets. YAML apply additionally requires a short-lived, @@ -350,6 +361,16 @@ diff. Port-forward accepts loopback addresses only (`localhost`, `127.0.0.1`, or can hold API connections for its lifetime, but it creates no cloud resources or persistent local cache. +On macOS, `sith desktop` runs the same embedded fleet IDE in a native Wails v2 window. It uses an +in-process WebView origin (`wails://wails`), so it does not open a TCP listener. The **Import folder** +control appears only in that window and opens a native directory chooser; it passes the selection to +the identical bounded, in-memory kubeconfig importer used by `sith ui --kubeconfig-dir`. The UI +receives success, cancellation, or a sanitized failure category—never the selected absolute path or +kubeconfig content. Build +an ad-hoc-signed Apple Silicon development bundle with `make desktop-build`; public releases remain +blocked on Developer ID signing, notarization, stapling, and E9 release provenance, so this is not yet +a distributed replacement for Lens. + Each active lens holds one Kubernetes watch per reachable context after its initial list. A two-minute safety rediscovery recovers contexts that were offline at launch; it is not the primary resource refresh path. Very large context/lens counts therefore trade API-server connection and diff --git a/cmd/sith-desktop/frontend/.gitkeep b/cmd/sith-desktop/frontend/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/cmd/sith-desktop/frontend/.gitkeep @@ -0,0 +1 @@ + diff --git a/cmd/sith-desktop/main.go b/cmd/sith-desktop/main.go new file mode 100644 index 0000000..8e4c77a --- /dev/null +++ b/cmd/sith-desktop/main.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Sith desktop starts the native macOS shell for the local fleet IDE. +package main + +import ( + "os" + + "github.com/ArdurAI/sith/internal/cli" +) + +func main() { + os.Exit(cli.ExecuteDesktop()) +} diff --git a/cmd/sith-desktop/wails.json b/cmd/sith-desktop/wails.json new file mode 100644 index 0000000..f4ed803 --- /dev/null +++ b/cmd/sith-desktop/wails.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "Sith", + "outputfilename": "Sith", + "frontend:dir": "frontend", + "frontend:install": "", + "frontend:build": "true", + "author": { + "name": "ArdurAI", + "email": "security@ardur.ai" + }, + "info": { + "companyName": "ArdurAI", + "productName": "Sith", + "productVersion": "0.0.0-dev", + "comments": "Local-first Kubernetes fleet IDE" + } +} diff --git a/docs/adr/0010-native-local-desktop-shell.md b/docs/adr/0010-native-local-desktop-shell.md new file mode 100644 index 0000000..65cc649 --- /dev/null +++ b/docs/adr/0010-native-local-desktop-shell.md @@ -0,0 +1,46 @@ +# ADR 0010: Use a Wails v2 native shell for the local fleet IDE + +- Status: Accepted +- Date: 2026-07-14 + +## Context + +Sith already provides a build-free, loopback-only browser IDE through `sith ui`. +Operators also need a macOS application that feels local, including a native folder +chooser for a directory of kubeconfig files. The desktop form must retain the same +source-abstract engine, cache, local-operation boundaries, and privacy posture. + +## Decision + +Use Wails v2 as a thin macOS shell around the existing Go web UI handler. + +- Wails v2 is the upstream stable release line; Wails v3 is alpha and is not used. +- The app serves `webui.Application` through the Wails in-process asset server at + the exact `wails://wails` origin. It opens no TCP listener. +- The existing API handler, strict Host/Origin checks, per-process CSRF capability, + CSP, cache, hydrator, and local operation client remain the only implementation. +- The sole native binding opens a directory chooser. It returns success, + cancellation, or a sanitized failure category to the UI; the selected path + and kubeconfig contents never cross the UI bridge, persist, or enter diagnostics. +- A successful selection builds a new bounded importer session before atomically + replacing the current in-memory session. A failing selection leaves the current + session intact. + +## Consequences + +The normal CLI remains browser-capable through `sith ui`, while `sith desktop` +opens the same fleet IDE as a local macOS window. `make desktop-build` produces a +development ARM64 `.app` with the stable `com.ardurai.sith` bundle identifier and +an ad-hoc signature. It is deliberately not a public release artifact until E9 +supplies Developer ID signing, notarization, stapling, and release provenance. + +The first native shell does not add complete Lens parity, telemetry, an updater, +remote control-plane access, Windows/Linux desktop support, or a second Kubernetes +client. Its Wails dependency and macOS runtime therefore become explicit package +review and release-gate responsibilities. + +## References + +- https://wails.io/docs/introduction/ +- https://wails.io/docs/guides/dynamic-assets/ +- https://wails.io/docs/guides/signing/ diff --git a/docs/adr/README.md b/docs/adr/README.md index 0e7221c..5cd0dcf 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,6 +19,7 @@ decision rests on an external fact, that fact is web-verified and cited (see als | [0007](0007-local-mcp-transport-auth.md) | Local MCP transport, scope, and authentication | Accepted | | [0008](0008-deterministic-advisory-brain.md) | Deterministic local advisory brain and evidence contract | Accepted | | [0009](0009-release-supply-chain.md) | Reproducible and identity-bound release supply chain | Accepted | +| [0010](0010-native-local-desktop-shell.md) | Native local desktop shell | Accepted | Planning ADRs remain **Proposed** until their implementation lane accepts or rejects them. Implementation-specific ADRs may be **Accepted** when the corresponding shipped slice provides diff --git a/go.mod b/go.mod index 0b98af2..4bb888f 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.10.2 + github.com/wailsapp/wails/v2 v2.12.0 github.com/zalando/go-keyring v0.2.8 go.yaml.in/yaml/v3 v3.0.4 golang.org/x/term v0.45.0 @@ -25,7 +26,9 @@ require ( ) require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bep/debounce v1.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect @@ -40,6 +43,7 @@ require ( 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-ole/go-ole v1.3.0 // 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 @@ -52,27 +56,45 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect github.com/moby/spdystream v0.5.1 // 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/muesli/cancelreader v0.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.21.0 // indirect diff --git a/go.sum b/go.sum index d80cef2..cf62e0f 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,15 @@ charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= @@ -42,6 +46,8 @@ 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-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 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= @@ -77,6 +83,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= 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= @@ -92,10 +100,32 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= @@ -112,6 +142,10 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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= @@ -123,11 +157,14 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= @@ -148,6 +185,18 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO 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/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -174,22 +223,34 @@ 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= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= diff --git a/internal/cli/desktop.go b/internal/cli/desktop.go new file mode 100644 index 0000000..9264e2a --- /dev/null +++ b/internal/cli/desktop.go @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + + "github.com/spf13/cobra" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/connector/kubeconfig" + "github.com/ArdurAI/sith/internal/fleetcache" + "github.com/ArdurAI/sith/internal/hydrate" + "github.com/ArdurAI/sith/internal/localops" + "github.com/ArdurAI/sith/internal/webui" +) + +type desktopOptions struct { + kubeconfigDir string +} + +func newDesktopCommand(reader connector.Reader, local localops.Client) *cobra.Command { + options := &desktopOptions{} + command := &cobra.Command{ + Use: "desktop", + Short: "Open the native local fleet IDE on macOS", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if err := validateDesktopDependencies(reader, local, options.kubeconfigDir); err != nil { + return err + } + return runDesktop(command.Context(), reader, local, options.kubeconfigDir) + }, + } + command.Flags().StringVar(&options.kubeconfigDir, "kubeconfig-dir", "", "import kubeconfig files from this directory for this local desktop session") + return command +} + +type desktopSourceFactory func(string) (connector.Reader, localops.Client, error) + +type desktopSession struct { + cancel context.CancelFunc + application *webui.Application + handler webui.LocalHandler +} + +func newDesktopSession(parent context.Context, reader connector.Reader, local localops.Client) (*desktopSession, error) { + ctx, cancel := context.WithCancel(parent) + store := fleetcache.New() + hydrator, err := hydrate.New(reader, store) + if err != nil { + cancel() + return nil, err + } + application, err := webui.New(ctx, store, hydrator, local) + if err != nil { + cancel() + return nil, err + } + handler, err := application.Handler(webui.DesktopOrigin) + if err != nil { + _ = application.Close() + cancel() + return nil, err + } + go runDesktopHydration(ctx, store, hydrator.Run) + return &desktopSession{cancel: cancel, application: application, handler: handler}, nil +} + +const desktopHydrationStopped = "live cache refresh stopped; re-import the folder or restart Sith" + +func runDesktopHydration(ctx context.Context, store *fleetcache.Store, run func(context.Context) error) { + if err := run(ctx); err != nil && ctx.Err() == nil { + // The cache/API exposes only a closed operational category. Raw watch + // errors can carry cluster-specific details and do not cross this boundary. + store.EndSync(errors.New(desktopHydrationStopped)) + } +} + +// quitDesktopOnCancellation defers native shutdown until Wails has supplied +// its application context, while allowing normal application shutdown to win. +func quitDesktopOnCancellation(parent context.Context, started <-chan context.Context, stopped <-chan struct{}, quit func(context.Context)) { + select { + case <-parent.Done(): + select { + case appContext := <-started: + quit(appContext) + case <-stopped: + } + case <-stopped: + } +} + +func (session *desktopSession) close() { + if session == nil { + return + } + session.cancel() + _ = session.application.Close() +} + +// desktopHost swaps complete in-memory sessions after a native folder choice. +// It never persists or returns the selected filesystem path. +type desktopHost struct { + ctx context.Context + newSource desktopSourceFactory + + mu sync.RWMutex + closed bool + session *desktopSession + handler *webui.InProcessHandler +} + +func newDesktopHost(ctx context.Context, reader connector.Reader, local localops.Client) (*desktopHost, error) { + if reader == nil || local == nil { + return nil, fmt.Errorf("construct local fleet desktop: Kubernetes access is unavailable") + } + host := &desktopHost{ + ctx: ctx, + newSource: desktopDirectorySource, + } + session, err := newDesktopSession(ctx, reader, local) + if err != nil { + return nil, err + } + host.session = session + host.handler = webui.NewInProcessHandler(session.handler) + return host, nil +} + +func desktopDirectorySource(directory string) (connector.Reader, localops.Client, error) { + adapter, err := kubeconfig.New(kubeconfig.WithDirectory(directory)) + if err != nil { + return nil, nil, err + } + return adapter, adapter, nil +} + +func validateDesktopDependencies(reader connector.Reader, local localops.Client, directory string) error { + if strings.TrimSpace(directory) != "" { + return nil + } + if reader == nil || local == nil { + return fmt.Errorf("local fleet desktop requires a Kubernetes reader and local operations client") + } + return nil +} + +func (host *desktopHost) Handler() webui.LocalHandler { + return host.handler +} + +func (host *desktopHost) importDirectory(directory string) error { + if strings.TrimSpace(directory) == "" { + return fmt.Errorf("import selected kubeconfig directory") + } + reader, local, err := host.newSource(directory) + if err != nil { + return fmt.Errorf("import selected kubeconfig directory") + } + next, err := newDesktopSession(host.ctx, reader, local) + if err != nil { + return fmt.Errorf("open selected kubeconfig directory") + } + host.mu.Lock() + if host.closed { + host.mu.Unlock() + next.close() + return fmt.Errorf("open selected kubeconfig directory") + } + previous := host.session + drained := host.handler.Replace(next.handler) + host.session = next + host.mu.Unlock() + go closeDesktopSessionAfter(drained, previous) + return nil +} + +func closeDesktopSessionAfter(drained <-chan struct{}, session *desktopSession) { + if drained == nil || session == nil { + return + } + <-drained + session.close() +} + +func (host *desktopHost) Close() { + host.mu.Lock() + if host.closed { + host.mu.Unlock() + return + } + host.closed = true + session := host.session + host.session = nil + host.handler.Replace(nil) + host.mu.Unlock() + session.close() +} diff --git a/internal/cli/desktop_darwin.go b/internal/cli/desktop_darwin.go new file mode 100644 index 0000000..4006b5d --- /dev/null +++ b/internal/cli/desktop_darwin.go @@ -0,0 +1,101 @@ +//go:build darwin + +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "fmt" + "sync" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/runtime" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/localops" + "github.com/ArdurAI/sith/internal/webui" +) + +// DesktopBridge is the only native capability exposed to the embedded UI. +// It returns a boolean, never the selected directory path. +type DesktopBridge struct { + ctx context.Context + host *desktopHost +} + +// ChooseKubeconfigDirectory opens the native directory picker and replaces the +// in-memory source only after the existing bounded importer accepts it. +func (bridge *DesktopBridge) ChooseKubeconfigDirectory() (bool, error) { + directory, err := runtime.OpenDirectoryDialog(bridge.ctx, runtime.OpenDialogOptions{ + Title: "Import kubeconfig folder", + CanCreateDirectories: false, + ShowHiddenFiles: false, + }) + if err != nil { + // Native dialog errors may include local filesystem details, so never + // return the underlying error across the WebView bridge. + return false, fmt.Errorf("select kubeconfig directory") + } + if directory == "" { + return false, nil + } + if err := bridge.host.importDirectory(directory); err != nil { + return false, err + } + return true, nil +} + +func runDesktop(ctx context.Context, reader connector.Reader, local localops.Client, directory string) error { + if directory != "" { + var err error + reader, local, err = desktopDirectorySource(directory) + if err != nil { + // Import errors can contain a selected local path; the CLI receives + // a stable category rather than that private detail. + return fmt.Errorf("import selected kubeconfig directory") + } + } + host, err := newDesktopHost(ctx, reader, local) + if err != nil { + return err + } + bridge := &DesktopBridge{host: host} + started := make(chan context.Context, 1) + stopped := make(chan struct{}) + var stopOnce sync.Once + stop := func() { stopOnce.Do(func() { close(stopped) }) } + go quitDesktopOnCancellation(ctx, started, stopped, runtime.Quit) + err = wails.Run(&options.App{ + Title: "Sith — Fleet IDE", + Width: 1440, + Height: 900, + MinWidth: 960, + MinHeight: 640, + BackgroundColour: &options.RGBA{R: 16, G: 24, B: 32, A: 255}, + OnStartup: func(appContext context.Context) { + bridge.ctx = appContext + select { + case started <- appContext: + case <-stopped: + } + }, + OnShutdown: func(context.Context) { + stop() + host.Close() + }, + Bind: []interface{}{bridge}, + EnableDefaultContextMenu: false, + AssetServer: &assetserver.Options{ + Middleware: webui.InProcessMiddleware(host.Handler()), + }, + }) + stop() + host.Close() + if err != nil { + return fmt.Errorf("start local fleet desktop: %w", err) + } + return nil +} diff --git a/internal/cli/desktop_execute.go b/internal/cli/desktop_execute.go new file mode 100644 index 0000000..acff144 --- /dev/null +++ b/internal/cli/desktop_execute.go @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "fmt" + "os" + "os/signal" + + "github.com/ArdurAI/sith/internal/connector/kubeconfig" +) + +// ExecuteDesktop runs the packaged macOS application entry point. +func ExecuteDesktop() int { + adapter := kubeconfig.Default() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + if err := runDesktop(ctx, adapter, adapter, ""); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + return 1 + } + return 0 +} diff --git a/internal/cli/desktop_other.go b/internal/cli/desktop_other.go new file mode 100644 index 0000000..67bbe8d --- /dev/null +++ b/internal/cli/desktop_other.go @@ -0,0 +1,17 @@ +//go:build !darwin + +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "fmt" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/localops" +) + +func runDesktop(context.Context, connector.Reader, localops.Client, string) error { + return fmt.Errorf("local fleet desktop is currently available only on macOS") +} diff --git a/internal/cli/desktop_test.go b/internal/cli/desktop_test.go new file mode 100644 index 0000000..51b9b7c --- /dev/null +++ b/internal/cli/desktop_test.go @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "sync" + "testing" + "time" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleet" + "github.com/ArdurAI/sith/internal/fleetcache" + "github.com/ArdurAI/sith/internal/localops" + "github.com/ArdurAI/sith/internal/webui" +) + +func TestDesktopHostServesTheExistingUIWithoutATCPListener(t *testing.T) { + t.Parallel() + host, err := newDesktopHost(t.Context(), &cacheReader{}, &fakeLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(host.Close) + indexRequest := httptest.NewRequest(http.MethodGet, webui.DesktopOrigin+"/", nil) + indexRequest.Host = "wails" + index := httptest.NewRecorder() + host.Handler().ServeHTTP(index, indexRequest) + match := regexp.MustCompile(`name="sith-csrf-token" content="([^"]+)"`).FindStringSubmatch(index.Body.String()) + if index.Code != http.StatusOK || len(match) != 2 { + t.Fatalf("desktop index = %d/%s", index.Code, index.Body.String()) + } + request := httptest.NewRequest(http.MethodGet, webui.DesktopOrigin+"/api/v1/meta", nil) + request.Host = "wails" + request.Header.Set("X-Sith-CSRF", match[1]) + request.Header.Set("Origin", webui.DesktopOrigin) + response := httptest.NewRecorder() + host.Handler().ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"telemetry":false`) { + t.Fatalf("desktop response = %d/%s", response.Code, response.Body.String()) + } +} + +func TestDesktopFolderImportUsesTheSharedSourceSeam(t *testing.T) { + t.Parallel() + host, err := newDesktopHost(t.Context(), &cacheReader{}, &fakeLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(host.Close) + selected := t.TempDir() + "/team-kubeconfigs" + called := "" + host.newSource = func(directory string) (connector.Reader, localops.Client, error) { + called = directory + return &cacheReader{}, &fakeLocalClient{}, nil + } + if err := host.importDirectory(selected); err != nil { + t.Fatal(err) + } + if called != selected { + t.Fatalf("selected directory = %q, want %q", called, selected) + } +} + +func TestDesktopFolderImportRedactsFailure(t *testing.T) { + t.Parallel() + host, err := newDesktopHost(t.Context(), &cacheReader{}, &fakeLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(host.Close) + selected := t.TempDir() + "/team-kubeconfigs" + host.newSource = func(directory string) (connector.Reader, localops.Client, error) { + return nil, nil, fmt.Errorf("unreadable %s", directory) + } + if err := host.importDirectory(selected); err == nil || strings.Contains(err.Error(), selected) { + t.Fatalf("import failure = %v, want redacted error", err) + } +} + +func TestDesktopFolderImportKeepsTheActiveSessionWhenReplacementCannotStart(t *testing.T) { + t.Parallel() + host, err := newDesktopHost(t.Context(), &cacheReader{}, &fakeLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(host.Close) + previous := host.session + selected := t.TempDir() + "/team-kubeconfigs" + host.newSource = func(string) (connector.Reader, localops.Client, error) { + return &cacheReader{}, nil, nil + } + if err := host.importDirectory(selected); err == nil || strings.Contains(err.Error(), selected) { + t.Fatalf("import failure = %v, want redacted replacement error", err) + } + if host.session != previous { + t.Fatal("failed import replaced the active desktop session") + } + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, webui.DesktopOrigin+"/", nil) + request.Host = "wails" + host.Handler().ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("active session status after failed import = %d", response.Code) + } +} + +func TestDesktopFolderImportCannotReviveAClosedHost(t *testing.T) { + t.Parallel() + host, err := newDesktopHost(t.Context(), &cacheReader{}, &fakeLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(host.Close) + started := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + releaseSource := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(releaseSource) + host.newSource = func(string) (connector.Reader, localops.Client, error) { + close(started) + <-release + return &cacheReader{}, &fakeLocalClient{}, nil + } + result := make(chan error, 1) + go func() { result <- host.importDirectory(t.TempDir() + "/team-kubeconfigs") }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("import did not reach source construction") + } + host.Close() + releaseSource() + select { + case err := <-result: + if err == nil { + t.Fatal("closed desktop host accepted a replacement session") + } + case <-time.After(time.Second): + t.Fatal("closed desktop host did not finish the interrupted import") + } + if host.session != nil { + t.Fatal("closed desktop host retained a session") + } + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, webui.DesktopOrigin+"/", nil) + request.Host = "wails" + host.Handler().ServeHTTP(response, request) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("closed desktop handler status = %d, want %d", response.Code, http.StatusServiceUnavailable) + } +} + +func TestDesktopDirectorySourceRejectsUnsafeInputWithoutPathLeak(t *testing.T) { + t.Parallel() + unsafe := t.TempDir() + "/missing-kubeconfigs" + if _, _, err := desktopDirectorySource(unsafe); err == nil || strings.Contains(err.Error(), unsafe) { + t.Fatalf("desktopDirectorySource() error = %v, want safe rejection", err) + } +} + +func TestDesktopDependenciesAllowAnExplicitDirectorySource(t *testing.T) { + t.Parallel() + if err := validateDesktopDependencies(nil, nil, t.TempDir()); err != nil { + t.Fatalf("validateDesktopDependencies() error = %v, want explicit directory accepted", err) + } + if err := validateDesktopDependencies(nil, nil, ""); err == nil { + t.Fatal("validateDesktopDependencies() error = nil, want missing default source rejected") + } +} + +func TestDesktopHydrationFailureIsSanitizedInTheFleetCache(t *testing.T) { + t.Parallel() + store := fleetcache.New() + runDesktopHydration(context.Background(), store, func(context.Context) error { + return fmt.Errorf("watch /private/kubeconfigs/team.yaml failed") + }) + snapshot := store.Query(fleet.LocalWorkspace, fleetcache.Query{}) + if snapshot.LastError != desktopHydrationStopped || strings.Contains(snapshot.LastError, "/private/") { + t.Fatalf("hydration failure = %q, want sanitized category", snapshot.LastError) + } +} + +func TestQuitDesktopOnCancellationAfterStartup(t *testing.T) { + t.Parallel() + parent, cancel := context.WithCancel(t.Context()) + defer cancel() + started := make(chan context.Context, 1) + stopped := make(chan struct{}) + quit := make(chan struct{}, 1) + go quitDesktopOnCancellation(parent, started, stopped, func(context.Context) { quit <- struct{}{} }) + started <- context.Background() + cancel() + select { + case <-quit: + case <-time.After(time.Second): + t.Fatal("desktop cancellation did not request native shutdown") + } +} diff --git a/internal/cli/local_test.go b/internal/cli/local_test.go index 2248e4e..9c913aa 100644 --- a/internal/cli/local_test.go +++ b/internal/cli/local_test.go @@ -182,6 +182,20 @@ func TestUIRefusesExternalBindAndStartsOnLoopback(t *testing.T) { } } +func TestUIKubeconfigDirectoryRefusesFileBeforeStartingListener(t *testing.T) { + filename := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(filename, []byte("apiVersion: v1\n"), 0o600); err != nil { + t.Fatal(err) + } + stdout, stderr, exitCode := runUICLI(context.Background(), t, []string{ + "ui", "--no-open", "--kubeconfig-dir", filename, + }, &cacheReader{}, &fakeLocalClient{}) + if exitCode == 0 || stdout != "" || !strings.Contains(stderr, "must be a real directory") || + strings.Contains(stderr, filename) || strings.Contains(stderr, "apiVersion: v1") { + t.Fatalf("directory refusal exit/stdout/stderr = %d/%q/%q", exitCode, stdout, stderr) + } +} + func runLocalCLI( t *testing.T, args []string, diff --git a/internal/cli/root.go b/internal/cli/root.go index b80cf14..a8c93fa 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -144,6 +144,7 @@ func newRootCommand(runtime backend, stdout, stderr io.Writer) *cobra.Command { newVersionCommand(options), newClustersCommand(options, runtime.source), newUICommand(runtime.reader, runtime.local), + newDesktopCommand(runtime.reader, runtime.local), newHubCommand(), } if runtime.reader != nil { diff --git a/internal/cli/ui.go b/internal/cli/ui.go index 6901b23..edfcabb 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -16,6 +16,7 @@ import ( "github.com/spf13/cobra" "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/connector/kubeconfig" "github.com/ArdurAI/sith/internal/fleetcache" "github.com/ArdurAI/sith/internal/hydrate" "github.com/ArdurAI/sith/internal/localops" @@ -23,9 +24,10 @@ import ( ) type uiOptions struct { - address string - port int - noOpen bool + address string + port int + noOpen bool + kubeconfigDir string } func newUICommand(reader connector.Reader, local localops.Client) *cobra.Command { @@ -38,12 +40,20 @@ func newUICommand(reader connector.Reader, local localops.Client) *cobra.Command if reader == nil || local == nil { return fmt.Errorf("local fleet UI requires a Kubernetes reader and local operations client") } + if options.kubeconfigDir != "" { + adapter, err := kubeconfig.New(kubeconfig.WithDirectory(options.kubeconfigDir)) + if err != nil { + return fmt.Errorf("import kubeconfig directory: %w", err) + } + reader, local = adapter, adapter + } return runWebUI(command.Context(), command, reader, local, options) }, } command.Flags().StringVar(&options.address, "address", options.address, "loopback listen address") command.Flags().IntVar(&options.port, "port", 0, "loopback listen port; 0 selects an available port") command.Flags().BoolVar(&options.noOpen, "no-open", false, "do not open the system browser") + command.Flags().StringVar(&options.kubeconfigDir, "kubeconfig-dir", "", "import kubeconfig files from this directory for this local UI session") return command } diff --git a/internal/connector/contract.go b/internal/connector/contract.go index f9b7980..0ec7f75 100644 --- a/internal/connector/contract.go +++ b/internal/connector/contract.go @@ -115,16 +115,26 @@ type WatchEvent struct { // Discovery describes the scopes a reader can currently address. type Discovery struct { - Scopes []Scope `json:"scopes"` - Unreachable []string `json:"unreachable,omitempty"` + Scopes []Scope `json:"scopes"` + Unreachable []string `json:"unreachable,omitempty"` + Diagnostics []Diagnostic `json:"diagnostics,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"` + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + Origin string `json:"origin,omitempty"` + Kinds []string `json:"kinds"` + Reachable bool `json:"reachable"` + ObservedAt time.Time `json:"observed_at,omitempty"` +} + +// Diagnostic is a bounded, safe-to-render discovery warning. It must never contain a credential, +// a kubeconfig payload, or an absolute local path. +type Diagnostic struct { + Source string `json:"source,omitempty"` + Message string `json:"message"` } // Differ computes desired-versus-observed state without mutation. diff --git a/internal/connector/kubeconfig/adapter.go b/internal/connector/kubeconfig/adapter.go index 8cf4e4e..dd2869d 100644 --- a/internal/connector/kubeconfig/adapter.go +++ b/internal/connector/kubeconfig/adapter.go @@ -57,6 +57,8 @@ type tableFactory func(config *rest.Config) (tablePrinter, error) type options struct { loadingRules *clientcmd.ClientConfigLoadingRules + importedConfig *importedConfig + customRules bool probeTimeout time.Duration requestTimeout time.Duration staleAfter time.Duration @@ -81,8 +83,12 @@ func WithLoadingRules(rules *clientcmd.ClientConfigLoadingRules) Option { if rules == nil { return fmt.Errorf("kubeconfig loading rules must not be nil") } + if settings.importedConfig != nil { + return fmt.Errorf("kubeconfig loading rules and directory import are mutually exclusive") + } copyRules := *rules settings.loadingRules = ©Rules + settings.customRules = true return nil } } @@ -90,6 +96,9 @@ func WithLoadingRules(rules *clientcmd.ClientConfigLoadingRules) Option { // WithExplicitPath reads one explicitly selected kubeconfig path. func WithExplicitPath(path string) Option { return func(settings *options) error { + if settings.importedConfig != nil && path != "" { + return fmt.Errorf("explicit kubeconfig path and directory import are mutually exclusive") + } if path != "" { settings.loadingRules.ExplicitPath = path } @@ -97,6 +106,23 @@ func WithExplicitPath(path string) Option { } } +// WithDirectory imports all bounded, regular kubeconfig files beneath one user-selected directory. +// The import is in-memory only: source files and credentials are never copied or persisted. +func WithDirectory(path string) Option { + return func(settings *options) error { + if settings.importedConfig != nil || settings.customRules || settings.loadingRules.ExplicitPath != "" { + return fmt.Errorf("kubeconfig directory import cannot be combined with another explicit kubeconfig source") + } + imported, err := loadDirectory(path) + if err != nil { + return err + } + settings.importedConfig = &imported + settings.loadingRules = &clientcmd.ClientConfigLoadingRules{} + return nil + } +} + // WithProbeTimeout sets the independent reachability deadline for each context. func WithProbeTimeout(timeout time.Duration) Option { return func(settings *options) error { @@ -277,9 +303,9 @@ func (adapter *Adapter) Descriptor() connector.Descriptor { // Discover enumerates every context and probes each independently. func (adapter *Adapter) Discover(ctx context.Context) (connector.Discovery, error) { - rawConfig, err := adapter.settings.loadingRules.Load() + rawConfig, metadata, diagnostics, err := adapter.loadConfig() if err != nil { - return connector.Discovery{}, fmt.Errorf("load kubeconfig: %w", err) + return connector.Discovery{}, err } names := make([]string, 0, len(rawConfig.Contexts)) @@ -291,7 +317,7 @@ func (adapter *Adapter) Discover(ctx context.Context) (connector.Discovery, erro results := make([]contextResult, len(names)) adapter.runBounded(len(names), func(index int) { - results[index] = adapter.probeContext(ctx, *rawConfig, names[index], priorLastSeen[names[index]]) + results[index] = adapter.probeContext(ctx, *rawConfig, names[index], metadata[names[index]], priorLastSeen[names[index]]) }) if err := ctx.Err(); err != nil { return connector.Discovery{}, fmt.Errorf("discover kubeconfig contexts: %w", err) @@ -333,7 +359,22 @@ func (adapter *Adapter) Discover(ctx context.Context) (connector.Discovery, erro adapter.lastSeen = lastSeen adapter.mu.Unlock() - return connector.Discovery{Scopes: cloneScopes(scopes), Unreachable: append([]string(nil), unreachable...)}, nil + return connector.Discovery{ + Scopes: cloneScopes(scopes), Unreachable: append([]string(nil), unreachable...), Diagnostics: cloneDiagnostics(diagnostics), + }, nil +} + +func (adapter *Adapter) loadConfig() (*clientcmdapi.Config, map[string]contextMetadata, []connector.Diagnostic, error) { + if adapter.settings.importedConfig != nil { + return adapter.settings.importedConfig.raw.DeepCopy(), + cloneContextMetadata(adapter.settings.importedConfig.metadata), + cloneDiagnostics(adapter.settings.importedConfig.diagnostics), nil + } + rawConfig, err := adapter.settings.loadingRules.Load() + if err != nil { + return nil, nil, nil, fmt.Errorf("load kubeconfig: %w", err) + } + return rawConfig, map[string]contextMetadata{}, nil, nil } type contextResult struct { @@ -348,12 +389,15 @@ func (adapter *Adapter) probeContext( ctx context.Context, rawConfig clientcmdapi.Config, name string, + metadata contextMetadata, lastSeen time.Time, ) contextResult { scope := connector.Scope{ - Name: name, - Kinds: append([]string(nil), supportedKinds...), - ObservedAt: lastSeen, + Name: name, DisplayName: name, Origin: metadata.origin, + Kinds: append([]string(nil), supportedKinds...), ObservedAt: lastSeen, + } + if metadata.displayName != "" { + scope.DisplayName = metadata.displayName } clientConfig := clientcmd.NewNonInteractiveClientConfig( rawConfig, @@ -493,3 +537,15 @@ func cloneScopes(scopes []connector.Scope) []connector.Scope { } return result } + +func cloneContextMetadata(values map[string]contextMetadata) map[string]contextMetadata { + result := make(map[string]contextMetadata, len(values)) + for key, value := range values { + result[key] = value + } + return result +} + +func cloneDiagnostics(values []connector.Diagnostic) []connector.Diagnostic { + return append([]connector.Diagnostic(nil), values...) +} diff --git a/internal/connector/kubeconfig/directory.go b/internal/connector/kubeconfig/directory.go new file mode 100644 index 0000000..4065eb4 --- /dev/null +++ b/internal/connector/kubeconfig/directory.go @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: Apache-2.0 + +package kubeconfig + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/ArdurAI/sith/internal/connector" +) + +const ( + maxImportFiles = 128 + maxImportBytes = 4 << 20 + maxImportDepth = 8 +) + +var errImportLimit = errors.New("kubeconfig directory import limit reached") + +type importedConfig struct { + raw *clientcmdapi.Config + metadata map[string]contextMetadata + diagnostics []connector.Diagnostic +} + +type contextMetadata struct { + displayName string + origin string +} + +// loadDirectory imports independently parsed kubeconfig files without following symlinks. The +// returned config is namespaced by an opaque per-file identifier so duplicate context names cannot +// silently shadow one another. +func loadDirectory(root string) (importedConfig, error) { + root = strings.TrimSpace(root) + if root == "" { + return importedConfig{}, fmt.Errorf("kubeconfig directory is required") + } + absolute, err := filepath.Abs(root) + if err != nil { + return importedConfig{}, errors.New("cannot resolve kubeconfig directory") + } + info, err := os.Lstat(absolute) + if err != nil { + return importedConfig{}, errors.New("cannot inspect kubeconfig directory") + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return importedConfig{}, fmt.Errorf("kubeconfig directory must be a real directory, not a symlink or file") + } + + result := importedConfig{ + raw: clientcmdapi.NewConfig(), + metadata: make(map[string]contextMetadata), + } + entries := 0 + err = filepath.WalkDir(absolute, func(path string, entry fs.DirEntry, walkErr error) error { + relative, relativeErr := filepath.Rel(absolute, path) + if relativeErr != nil { + return errors.New("cannot scan kubeconfig directory") + } + relative = filepath.ToSlash(relative) + if relative == "." { + if walkErr != nil { + return errors.New("cannot read kubeconfig directory") + } + return nil + } + entries++ + if entries > maxImportFiles { + result.diagnostics = append(result.diagnostics, importDiagnostic("", "kubeconfig entry limit reached")) + return errImportLimit + } + if walkErr != nil { + result.diagnostics = append(result.diagnostics, importDiagnostic(relative, "unreadable entry")) + if entry != nil && entry.IsDir() { + return filepath.SkipDir + } + return nil + } + if entry == nil { + result.diagnostics = append(result.diagnostics, importDiagnostic(relative, "unreadable entry")) + return nil + } + if entry.Type()&os.ModeSymlink != 0 { + result.diagnostics = append(result.diagnostics, importDiagnostic(relative, "symlink input ignored")) + return nil + } + if entry.IsDir() { + if importDepth(relative) > maxImportDepth { + result.diagnostics = append(result.diagnostics, importDiagnostic(relative, "directory depth limit reached")) + return filepath.SkipDir + } + return nil + } + if !entry.Type().IsRegular() { + return nil + } + fileInfo, statErr := entry.Info() + if statErr != nil || fileInfo.Size() > maxImportBytes { + message := "unreadable kubeconfig" + if statErr == nil { + message = "kubeconfig exceeds the import size limit" + } + result.diagnostics = append(result.diagnostics, importDiagnostic(relative, message)) + return nil + } + config, readErr := loadKubeconfigFile(path, fileInfo.Size()) + if readErr != nil { + result.diagnostics = append(result.diagnostics, importDiagnostic(relative, "invalid kubeconfig")) + return nil + } + mergeImportedConfig(result.raw, result.metadata, &result.diagnostics, config, relative) + return nil + }) + if errors.Is(err, errImportLimit) { + err = nil + } + if err != nil { + return importedConfig{}, errors.New("cannot scan kubeconfig directory") + } + sort.Slice(result.diagnostics, func(left, right int) bool { + if result.diagnostics[left].Source == result.diagnostics[right].Source { + return result.diagnostics[left].Message < result.diagnostics[right].Message + } + return result.diagnostics[left].Source < result.diagnostics[right].Source + }) + return result, nil +} + +func loadKubeconfigFile(path string, size int64) (*clientcmdapi.Config, error) { + if size < 0 || size > maxImportBytes { + return nil, fmt.Errorf("kubeconfig exceeds the import size limit") + } + file, err := os.Open(path) // #nosec G304 -- path is discovered beneath a user-selected directory without symlink traversal. + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + payload, err := io.ReadAll(io.LimitReader(file, maxImportBytes+1)) + if err != nil { + return nil, err + } + if len(payload) > maxImportBytes { + return nil, fmt.Errorf("kubeconfig exceeds the import size limit") + } + config, err := clientcmd.Load(payload) + if err != nil { + return nil, err + } + setLocationOfOrigin(config, path) + if err := clientcmd.ResolveLocalPaths(config); err != nil { + return nil, err + } + return config, nil +} + +func setLocationOfOrigin(config *clientcmdapi.Config, path string) { + for _, authInfo := range config.AuthInfos { + authInfo.LocationOfOrigin = path + } + for _, cluster := range config.Clusters { + cluster.LocationOfOrigin = path + } + for _, context := range config.Contexts { + context.LocationOfOrigin = path + } +} + +func mergeImportedConfig( + destination *clientcmdapi.Config, + metadata map[string]contextMetadata, + diagnostics *[]connector.Diagnostic, + source *clientcmdapi.Config, + origin string, +) { + identifier := importIdentifier(origin) + clusters := make(map[string]string, len(source.Clusters)) + for _, name := range sortedMapKeys(source.Clusters) { + qualified := identifier + "/cluster/" + name + clusters[name] = qualified + destination.Clusters[qualified] = source.Clusters[name].DeepCopy() + } + users := make(map[string]string, len(source.AuthInfos)) + for _, name := range sortedMapKeys(source.AuthInfos) { + qualified := identifier + "/user/" + name + users[name] = qualified + destination.AuthInfos[qualified] = source.AuthInfos[name].DeepCopy() + } + for _, name := range sortedMapKeys(source.Contexts) { + qualified := identifier + "/context/" + name + context := source.Contexts[name] + if context == nil { + *diagnostics = append(*diagnostics, importDiagnostic(origin, "kubeconfig contains an invalid context")) + continue + } + cluster, clusterExists := clusters[context.Cluster] + if !clusterExists { + *diagnostics = append(*diagnostics, importDiagnostic(origin, "context references an unavailable cluster")) + continue + } + user := "" + if context.AuthInfo != "" { + var userExists bool + user, userExists = users[context.AuthInfo] + if !userExists { + *diagnostics = append(*diagnostics, importDiagnostic(origin, "context references an unavailable user")) + continue + } + } + context = context.DeepCopy() + context.Cluster = cluster + context.AuthInfo = user + destination.Contexts[qualified] = context + metadata[qualified] = contextMetadata{displayName: name, origin: origin} + } +} + +func importIdentifier(origin string) string { + digest := sha256.Sum256([]byte(origin)) + return "import-" + hex.EncodeToString(digest[:8]) +} + +func sortedMapKeys[T any](values map[string]*T) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func importDepth(relative string) int { + if relative == "" || relative == "." { + return 0 + } + return len(strings.Split(relative, "/")) +} + +func importDiagnostic(source, message string) connector.Diagnostic { + return connector.Diagnostic{Source: source, Message: message} +} diff --git a/internal/connector/kubeconfig/directory_test.go b/internal/connector/kubeconfig/directory_test.go new file mode 100644 index 0000000..bb169f8 --- /dev/null +++ b/internal/connector/kubeconfig/directory_test.go @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: Apache-2.0 + +package kubeconfig + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +func TestDirectoryImportPreservesDuplicateContextNames(t *testing.T) { + t.Parallel() + directory := t.TempDir() + writeDirectoryConfig(t, filepath.Join(directory, "alpha.yaml"), "shared", "https://alpha.invalid") + writeDirectoryConfig(t, filepath.Join(directory, "nested", "beta.yaml"), "shared", "https://beta.invalid") + + var mu sync.Mutex + probed := make([]string, 0, 2) + adapter, err := New( + WithDirectory(directory), + withProbe(func(_ context.Context, config *rest.Config) error { + mu.Lock() + defer mu.Unlock() + probed = append(probed, config.Host) + return nil + }), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + discovery, err := adapter.Discover(t.Context()) + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + if len(discovery.Scopes) != 2 || len(discovery.Unreachable) != 0 || len(discovery.Diagnostics) != 0 { + t.Fatalf("Discovery = %#v, want two clean imported scopes", discovery) + } + slices.Sort(probed) + if !slices.Equal(probed, []string{"https://alpha.invalid", "https://beta.invalid"}) { + t.Fatalf("probed hosts = %v", probed) + } + origins := make([]string, 0, len(discovery.Scopes)) + names := make([]string, 0, len(discovery.Scopes)) + for _, scope := range discovery.Scopes { + if scope.DisplayName != "shared" || !scope.Reachable || strings.Contains(scope.Origin, directory) { + t.Fatalf("scope = %#v, want reachable relative-name metadata", scope) + } + origins = append(origins, scope.Origin) + names = append(names, scope.Name) + } + slices.Sort(origins) + if !slices.Equal(origins, []string{"alpha.yaml", "nested/beta.yaml"}) || names[0] == names[1] { + t.Fatalf("origins/names = %v/%v, want distinct imported contexts", origins, names) + } +} + +func TestDirectoryImportSurfacesSafeDiagnostics(t *testing.T) { + t.Parallel() + directory := t.TempDir() + writeDirectoryConfig(t, filepath.Join(directory, "valid.yaml"), "alpha", "https://alpha.invalid") + secret := "not-a-real-secret-but-must-not-leak" + if err := os.WriteFile(filepath.Join(directory, "broken.yaml"), []byte("clusters: ["+secret), 0o600); err != nil { + t.Fatal(err) + } + outside := filepath.Join(t.TempDir(), "outside.yaml") + writeDirectoryConfig(t, outside, "outside", "https://outside.invalid") + if err := os.Symlink(outside, filepath.Join(directory, "linked.yaml")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "large.yaml"), make([]byte, maxImportBytes+1), 0o600); err != nil { + t.Fatal(err) + } + + adapter, err := New(WithDirectory(directory), withProbe(func(_ context.Context, _ *rest.Config) error { return nil })) + if err != nil { + t.Fatalf("New() error = %v", err) + } + discovery, err := adapter.Discover(t.Context()) + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + if len(discovery.Scopes) != 1 || len(discovery.Diagnostics) != 3 { + t.Fatalf("Discovery = %#v, want one valid scope and three diagnostics", discovery) + } + payload, err := json.Marshal(discovery) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{directory, outside, secret} { + if strings.Contains(string(payload), forbidden) { + t.Fatalf("safe discovery metadata leaked %q: %s", forbidden, payload) + } + } + for _, source := range []string{"broken.yaml", "large.yaml", "linked.yaml"} { + if !strings.Contains(string(payload), source) { + t.Errorf("diagnostics = %s, want source %q", payload, source) + } + } +} + +func TestDirectoryImportRejectsUnsafeRoots(t *testing.T) { + t.Parallel() + regularFile := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(regularFile, []byte("apiVersion: v1\n"), 0o600); err != nil { + t.Fatal(err) + } + linkedRoot := filepath.Join(t.TempDir(), "linked-root") + if err := os.Symlink(t.TempDir(), linkedRoot); err != nil { + t.Fatal(err) + } + missing := filepath.Join(t.TempDir(), "missing") + for _, path := range []string{"", regularFile, linkedRoot, missing} { + if _, err := New(WithDirectory(path)); err == nil { + t.Errorf("New(WithDirectory(%q)) error = nil, want unsafe root refusal", path) + } else if path != "" && strings.Contains(err.Error(), path) { + t.Errorf("New(WithDirectory(%q)) exposed the path in %q", path, err) + } + } +} + +func TestDirectoryImportBoundsAllTraversalEntries(t *testing.T) { + t.Parallel() + directory := t.TempDir() + target := filepath.Join(t.TempDir(), "outside.yaml") + if err := os.WriteFile(target, []byte("apiVersion: v1\n"), 0o600); err != nil { + t.Fatal(err) + } + for index := 0; index <= maxImportFiles; index++ { + path := filepath.Join(directory, fmt.Sprintf("entry-%03d", index)) + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + } + imported, err := loadDirectory(directory) + if err != nil { + t.Fatalf("loadDirectory() error = %v", err) + } + limitReported := false + for _, diagnostic := range imported.diagnostics { + limitReported = limitReported || (diagnostic.Source == "" && diagnostic.Message == "kubeconfig entry limit reached") + } + if len(imported.diagnostics) != maxImportFiles+1 || !limitReported { + t.Fatalf("diagnostics = %#v, want bounded traversal diagnostic", imported.diagnostics) + } +} + +func TestDirectoryImportConflictsWithExplicitSource(t *testing.T) { + t.Parallel() + directory := t.TempDir() + if _, err := New(WithExplicitPath(filepath.Join(directory, "config")), WithDirectory(directory)); err == nil { + t.Fatal("New() error = nil, want explicit-path/directory conflict") + } + if _, err := New(WithLoadingRules(&clientcmd.ClientConfigLoadingRules{}), WithDirectory(directory)); err == nil { + t.Fatal("New() error = nil, want custom-rules/directory conflict") + } +} + +func TestStandardKubeconfigUsesContextNameAsDisplayLabel(t *testing.T) { + t.Parallel() + adapter, err := New( + WithLoadingRules(testLoadingRules(t, testConfig("alpha"))), + withProbe(func(_ context.Context, _ *rest.Config) error { return nil }), + ) + if err != nil { + t.Fatal(err) + } + discovery, err := adapter.Discover(t.Context()) + if err != nil || len(discovery.Scopes) != 1 { + t.Fatalf("Discover() = %#v, %v", discovery, err) + } + if scope := discovery.Scopes[0]; scope.Name != "alpha" || scope.DisplayName != "alpha" || scope.Origin != "" { + t.Fatalf("standard scope = %#v", scope) + } +} + +func TestDirectoryImportSkipsBrokenContextReferences(t *testing.T) { + t.Parallel() + directory := t.TempDir() + config := clientcmdapi.NewConfig() + config.Clusters["alpha"] = &clientcmdapi.Cluster{Server: "https://alpha.invalid"} + config.AuthInfos["alpha"] = &clientcmdapi.AuthInfo{} + config.Contexts["alpha"] = &clientcmdapi.Context{Cluster: "alpha", AuthInfo: "alpha"} + config.Contexts["missing-cluster"] = &clientcmdapi.Context{Cluster: "gone", AuthInfo: "alpha"} + config.Contexts["missing-user"] = &clientcmdapi.Context{Cluster: "alpha", AuthInfo: "gone"} + path := filepath.Join(directory, "mixed.yaml") + if err := clientcmd.WriteToFile(*config, path); err != nil { + t.Fatal(err) + } + probes := 0 + adapter, err := New(WithDirectory(directory), withProbe(func(_ context.Context, _ *rest.Config) error { + probes++ + return nil + })) + if err != nil { + t.Fatal(err) + } + discovery, err := adapter.Discover(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(discovery.Scopes) != 1 || probes != 1 { + t.Fatalf("discovery/probes = %#v/%d, want one valid context only", discovery, probes) + } + messages := make([]string, 0, len(discovery.Diagnostics)) + for _, diagnostic := range discovery.Diagnostics { + if diagnostic.Source != "mixed.yaml" { + t.Fatalf("diagnostic = %#v, want relative source", diagnostic) + } + messages = append(messages, diagnostic.Message) + } + slices.Sort(messages) + if !slices.Equal(messages, []string{"context references an unavailable cluster", "context references an unavailable user"}) { + t.Fatalf("diagnostics = %v", messages) + } +} + +func writeDirectoryConfig(t *testing.T, path, contextName, host string) { + t.Helper() + config := clientcmdapi.NewConfig() + config.Clusters[contextName] = &clientcmdapi.Cluster{Server: host} + config.AuthInfos[contextName] = &clientcmdapi.AuthInfo{} + config.Contexts[contextName] = &clientcmdapi.Context{Cluster: contextName, AuthInfo: contextName} + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := clientcmd.WriteToFile(*config, path); err != nil { + t.Fatalf("write kubeconfig %s: %v", path, err) + } +} diff --git a/internal/connector/kubeconfig/local_objects.go b/internal/connector/kubeconfig/local_objects.go index 39f534b..5c03b68 100644 --- a/internal/connector/kubeconfig/local_objects.go +++ b/internal/connector/kubeconfig/local_objects.go @@ -232,15 +232,15 @@ func (adapter *Adapter) ensureLocalContext(ctx context.Context, name string) err if known { return nil } - rawConfig, err := adapter.settings.loadingRules.Load() + rawConfig, metadata, _, err := adapter.loadConfig() if err != nil { - return fmt.Errorf("load kubeconfig: %w", err) + return err } if _, exists := rawConfig.Contexts[name]; !exists { return fmt.Errorf("%w: %s", ErrUnknownScope, name) } lastSeen := adapter.lastSeenSnapshot()[name] - result := adapter.probeContext(ctx, *rawConfig, name, lastSeen) + result := adapter.probeContext(ctx, *rawConfig, name, metadata[name], lastSeen) adapter.mu.Lock() adapter.scopes[name] = cloneScope(result.scope) if result.scope.Reachable { diff --git a/internal/fleetcache/store.go b/internal/fleetcache/store.go index 8e1f397..8a3ab74 100644 --- a/internal/fleetcache/store.go +++ b/internal/fleetcache/store.go @@ -31,15 +31,16 @@ const ( // Snapshot is an immutable cache-only answer for one render interaction. type Snapshot struct { - Version uint64 `json:"version"` - State State `json:"state"` - Syncing bool `json:"syncing"` - Paused bool `json:"paused"` - Records []Record `json:"records"` - Coverage fleet.Coverage `json:"coverage"` - UpdatedAt time.Time `json:"updated_at,omitempty"` - LastError string `json:"last_error,omitempty"` - Scopes []connector.Scope `json:"scopes"` + Version uint64 `json:"version"` + State State `json:"state"` + Syncing bool `json:"syncing"` + Paused bool `json:"paused"` + Records []Record `json:"records"` + Coverage fleet.Coverage `json:"coverage"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + LastError string `json:"last_error,omitempty"` + Scopes []connector.Scope `json:"scopes"` + Diagnostics []connector.Diagnostic `json:"diagnostics,omitempty"` } // Store owns normalized last-known fleet state and never performs network I/O. @@ -50,6 +51,7 @@ type Store struct { coverage map[string]fleet.Coverage aliases map[string]string scopes map[string]connector.Scope + diagnostics map[string][]connector.Diagnostic scopeWorkspaces map[string]map[string]bool warmed map[string]bool expected map[string]bool @@ -74,6 +76,7 @@ func newStore(now func() time.Time, freshFor time.Duration) *Store { coverage: make(map[string]fleet.Coverage), aliases: make(map[string]string), scopes: make(map[string]connector.Scope), + diagnostics: make(map[string][]connector.Diagnostic), scopeWorkspaces: make(map[string]map[string]bool), warmed: make(map[string]bool), expected: make(map[string]bool), @@ -129,6 +132,7 @@ func (store *Store) SetDiscovery(workspace string, discovery connector.Discovery store.scopes[name] = scope store.markScopeWorkspaceLocked(workspace, name) } + store.diagnostics[workspace] = cloneDiagnostics(discovery.Diagnostics) store.notifyLocked() } @@ -336,7 +340,7 @@ func (store *Store) Query(workspace string, query Query) Snapshot { store.mu.RLock() defer store.mu.RUnlock() if strings.TrimSpace(workspace) == "" { - return Snapshot{State: StateCold, Records: []Record{}, Scopes: []connector.Scope{}} + return Snapshot{State: StateCold, Records: []Record{}, Scopes: []connector.Scope{}, Diagnostics: []connector.Diagnostic{}} } now := store.now().UTC() records := make([]Record, 0) @@ -382,15 +386,16 @@ func (store *Store) Query(workspace string, query Query) Snapshot { } pending := selectedKind != "" && !store.warmed[selectedKind] return Snapshot{ - Version: store.version, - State: store.stateLocked(coverage, store.recordCountLocked(workspace), pending), - Syncing: store.syncing, - Paused: store.paused, - Records: records, - Coverage: coverage, - UpdatedAt: store.updatedAt, - LastError: store.lastError, - Scopes: store.scopesLocked(workspace, query.Scopes), + Version: store.version, + State: store.stateLocked(coverage, store.recordCountLocked(workspace), pending), + Syncing: store.syncing, + Paused: store.paused, + Records: records, + Coverage: coverage, + UpdatedAt: store.updatedAt, + LastError: store.lastError, + Scopes: store.scopesLocked(workspace, query.Scopes), + Diagnostics: cloneDiagnostics(store.diagnostics[workspace]), } } @@ -635,3 +640,7 @@ func cloneScope(scope connector.Scope) connector.Scope { scope.Kinds = append([]string(nil), scope.Kinds...) return scope } + +func cloneDiagnostics(values []connector.Diagnostic) []connector.Diagnostic { + return append([]connector.Diagnostic(nil), values...) +} diff --git a/internal/fleetcache/store_test.go b/internal/fleetcache/store_test.go index b11320a..24dbc32 100644 --- a/internal/fleetcache/store_test.go +++ b/internal/fleetcache/store_test.go @@ -438,6 +438,25 @@ func TestParseCorrelationSupportsHealthAndImageForms(t *testing.T) { } } +func TestSnapshotRetainsSafeDiscoveryMetadata(t *testing.T) { + t.Parallel() + store := New() + now := time.Now().UTC() + store.SetDiscovery(fleet.LocalWorkspace, connector.Discovery{ + Scopes: []connector.Scope{{ + Name: "import-123/context/prod", DisplayName: "prod", Origin: "team-a.yaml", Reachable: true, ObservedAt: now, + }}, + Diagnostics: []connector.Diagnostic{{Source: "bad.yaml", Message: "invalid kubeconfig"}}, + }) + snapshot := store.Query(fleet.LocalWorkspace, Query{}) + if len(snapshot.Scopes) != 1 || snapshot.Scopes[0].DisplayName != "prod" || snapshot.Scopes[0].Origin != "team-a.yaml" { + t.Fatalf("scopes = %#v", snapshot.Scopes) + } + if !slices.Equal(snapshot.Diagnostics, []connector.Diagnostic{{Source: "bad.yaml", Message: "invalid kubeconfig"}}) { + t.Fatalf("diagnostics = %#v", snapshot.Diagnostics) + } +} + func podFact(t *testing.T, cluster, name, status, image string, observed time.Time) fleet.Fact { t.Helper() object := map[string]any{ diff --git a/internal/privacy/boundary_test.go b/internal/privacy/boundary_test.go index cdb3b9e..056a95e 100644 --- a/internal/privacy/boundary_test.go +++ b/internal/privacy/boundary_test.go @@ -43,7 +43,9 @@ var approvedNetworkImports = map[string]map[string]bool{ "internal/mcpserver/server.go": {"net": true, "net/http": true, "net/url": true}, "internal/observability/metrics.go": {"net/http": true}, "internal/webui/api.go": {"net/http": true}, - "internal/webui/server.go": {"net": true, "net/http": true, "net/url": true}, + // In-process Wails WebView routing; it has no socket listener or egress path. + "internal/webui/desktop.go": {"net/http": true}, + "internal/webui/server.go": {"net": true, "net/http": true, "net/url": true}, } var approvedFilesystemWrites = map[string]map[string]bool{ diff --git a/internal/webui/assets/app.css b/internal/webui/assets/app.css index 71e399b..c6e0c1b 100644 --- a/internal/webui/assets/app.css +++ b/internal/webui/assets/app.css @@ -213,6 +213,8 @@ h2 { margin-bottom: 0; font: 650 21px/1.15 var(--display); letter-spacing: -.02e .context-node[aria-current="true"] { color: var(--cobalt); } .context-node strong { display: block; overflow: hidden; text-overflow: ellipsis; font-size: 12px; } .context-node small { display: block; margin-top: 2px; color: var(--muted); font: 10px/1.2 var(--mono); } +.context-source { margin-top: 6px; border-top: 1px solid var(--line); } +.context-diagnostic { margin: 8px 4px; color: var(--amber); font: 10px/1.35 var(--mono); } .rail-note { color: var(--muted); font-size: 11px; line-height: 1.5; } .fleet-board { min-width: 0; padding: 22px; } diff --git a/internal/webui/assets/app.js b/internal/webui/assets/app.js index 0d3742b..8c88bac 100644 --- a/internal/webui/assets/app.js +++ b/internal/webui/assets/app.js @@ -1,6 +1,7 @@ "use strict"; const csrfToken = document.querySelector('meta[name="sith-csrf-token"]').content; +const desktopHydrationFailure = "live cache refresh stopped; re-import the folder or restart Sith"; const state = { meta: null, snapshot: null, @@ -18,7 +19,7 @@ const dom = Object.fromEntries([ "query-mode", "context-list", "board-heading", "board-kicker", "result-count", "fleet-rows", "empty-state", "coverage-line", "inspector-empty", "inspector-content", "inspector-kind", "inspector-name", "inspector-address", "inspector-facts", "operation-grid", "refresh-button", - "forwards-button", "forward-count", "toast-region", "action-dialog", "dialog-title", + "forwards-button", "forward-count", "import-folder-button", "toast-region", "action-dialog", "dialog-title", "dialog-kicker", "dialog-body", "dialog-actions", "dialog-close", "loading-template", ].map((id) => [id, document.getElementById(id)])); @@ -85,12 +86,14 @@ function renderSnapshot() { const snapshot = state.snapshot; const coverage = snapshot.coverage || {}; dom["coverage-count"].textContent = `${coverage.reachable || 0} of ${coverage.requested || 0} contexts answering`; - dom["coverage-detail"].textContent = snapshot.state === "offline" ? "Offline — last-known fleet remains visible." : coverageText(coverage); + const coverageDetail = snapshot.state === "offline" ? "Offline — last-known fleet remains visible." : coverageText(coverage); + const safeDesktopFailure = snapshot.last_error === desktopHydrationFailure ? snapshot.last_error : ""; + dom["coverage-detail"].textContent = safeDesktopFailure ? `${coverageDetail} · ${safeDesktopFailure}` : coverageDetail; dom["coverage-line"].textContent = coverageText(coverage); dom["board-heading"].textContent = state.correlate || state.query ? "Fleet results" : `${state.lens}s`; dom["board-kicker"].textContent = state.correlate ? "Correlation answer" : state.query ? "Filtered cache" : "Aggregated lens"; dom["result-count"].textContent = `${snapshot.records.length} cached row${snapshot.records.length === 1 ? "" : "s"}`; - renderContexts(snapshot.scopes || [], coverage); + renderContexts(snapshot.scopes || [], coverage, snapshot.diagnostics || []); renderRows(snapshot.records || []); if (state.selected) { const identity = recordIdentity(state.selected); @@ -99,7 +102,7 @@ function renderSnapshot() { renderInspector(); } -function renderContexts(scopes, coverage) { +function renderContexts(scopes, coverage, diagnostics) { const entries = []; const all = node("button", "context-node"); all.type = "button"; @@ -108,17 +111,38 @@ function renderContexts(scopes, coverage) { all.append(contextLabel("All contexts", compactCoverage(coverage))); all.addEventListener("click", () => { state.scope = ""; loadSnapshot(); }); entries.push(all); + const grouped = new Map(); for (const scope of scopes) { + const origin = scope.origin || ""; + if (!grouped.has(origin)) grouped.set(origin, []); + grouped.get(origin).push(scope); + } + for (const [origin, members] of grouped) { + if (origin) { + const sourceScopes = members.map((scope) => scope.name).join(","); + const source = node("button", "context-node context-source"); + source.type = "button"; + source.setAttribute("aria-current", String(state.scope === sourceScopes)); + source.append(contextLabel(origin, `${members.length} imported context${members.length === 1 ? "" : "s"}`)); + source.addEventListener("click", () => { state.scope = state.scope === sourceScopes ? "" : sourceScopes; state.selected = null; loadSnapshot(); }); + entries.push(source); + } + for (const scope of members) { const button = node("button", "context-node"); button.type = "button"; const isDown = !scope.reachable; const isStale = (coverage.stale || []).includes(scope.name); button.dataset.state = isDown ? "down" : isStale ? "stale" : "fresh"; button.setAttribute("aria-current", String(state.scope === scope.name)); - button.append(contextLabel(scope.name, `${isDown ? "unreachable" : isStale ? "stale" : "reachable"} · ${ageLabel(scope.observed_at)}`)); + button.append(contextLabel(scope.display_name || scope.name, `${isDown ? "unreachable" : isStale ? "stale" : "reachable"} · ${ageLabel(scope.observed_at)}`)); button.addEventListener("click", () => { state.scope = state.scope === scope.name ? "" : scope.name; loadSnapshot(); }); entries.push(button); } + } + for (const diagnostic of diagnostics) { + const source = diagnostic.source ? `${diagnostic.source}: ` : ""; + entries.push(node("div", "context-diagnostic", `Import warning — ${source}${diagnostic.message}`)); + } replaceChildren(dom["context-list"], entries); } @@ -396,6 +420,15 @@ dom["query-mode"].addEventListener("click", () => { }); dom["refresh-button"].addEventListener("click", async () => { try { await api("/api/v1/sync", {method: "POST", body: "{}"}); toast("Fleet refresh scheduled."); } catch (error) { toast(error.message, "error"); } }); dom["forwards-button"].addEventListener("click", showForwards); +const directoryPicker = window.go?.cli?.DesktopBridge?.ChooseKubeconfigDirectory; +if (typeof directoryPicker === "function") { + dom["import-folder-button"].hidden = false; + dom["import-folder-button"].addEventListener("click", async () => { + try { + if (await directoryPicker()) window.location.reload(); + } catch (error) { toast(error.message || "Unable to import folder.", "error"); } + }); +} dom["dialog-close"].addEventListener("click", () => dom["action-dialog"].close()); dom["action-dialog"].addEventListener("close", () => { state.logAbort?.abort(); state.logAbort = null; }); document.addEventListener("keydown", (event) => { diff --git a/internal/webui/assets/index.html b/internal/webui/assets/index.html index dfe89c7..479ffd8 100644 --- a/internal/webui/assets/index.html +++ b/internal/webui/assets/index.html @@ -24,7 +24,8 @@

Sith / live

warming contexts Cache rows appear as clusters answer. -
+
+
diff --git a/internal/webui/desktop.go b/internal/webui/desktop.go new file mode 100644 index 0000000..e9469a0 --- /dev/null +++ b/internal/webui/desktop.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 + +package webui + +import ( + "net/http" + "sync" +) + +// LocalHandler is a same-process HTTP request handler. It exists so a native +// WebView can reuse the hardened web UI without opening a TCP listener. +type LocalHandler interface { + ServeHTTP(http.ResponseWriter, *http.Request) +} + +// InProcessHandler routes requests to the active local UI session. A replaced +// session remains leased only by requests that selected it before the swap. +type InProcessHandler struct { + mu sync.Mutex + current *inProcessSession +} + +type inProcessSession struct { + handler LocalHandler + active uint64 + retired bool + drained chan struct{} +} + +// NewInProcessHandler constructs a handler whose active UI session can be +// replaced without starting a network listener. +func NewInProcessHandler(initial LocalHandler) *InProcessHandler { + handler := &InProcessHandler{} + if initial != nil { + handler.current = newInProcessSession(initial) + } + return handler +} + +func (handler *InProcessHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) { + session := handler.acquire() + if session == nil { + http.Error(response, "local desktop is shutting down", http.StatusServiceUnavailable) + return + } + defer handler.release(session) + session.handler.ServeHTTP(response, request) +} + +// Replace atomically selects the next local UI session. It returns a channel +// that closes once requests leased to the previous session have drained. A nil +// handler makes future requests fail closed without blocking new requests on an +// old, slow operation. +func (handler *InProcessHandler) Replace(next LocalHandler) <-chan struct{} { + handler.mu.Lock() + previous := handler.current + if next == nil { + handler.current = nil + } else { + handler.current = newInProcessSession(next) + } + if previous == nil { + handler.mu.Unlock() + return nil + } + previous.retired = true + if previous.active == 0 { + close(previous.drained) + } + drained := previous.drained + handler.mu.Unlock() + return drained +} + +func newInProcessSession(next LocalHandler) *inProcessSession { + return &inProcessSession{handler: next, drained: make(chan struct{})} +} + +func (handler *InProcessHandler) acquire() *inProcessSession { + handler.mu.Lock() + defer handler.mu.Unlock() + if handler.current == nil { + return nil + } + handler.current.active++ + return handler.current +} + +func (handler *InProcessHandler) release(session *inProcessSession) { + handler.mu.Lock() + defer handler.mu.Unlock() + session.active-- + if session.retired && session.active == 0 { + close(session.drained) + } +} + +// InProcessMiddleware adapts a local handler to Wails without using the +// framework default asset route or opening a listener. +func InProcessMiddleware(handler LocalHandler) func(http.Handler) http.Handler { + return func(http.Handler) http.Handler { return handler } +} diff --git a/internal/webui/desktop_test.go b/internal/webui/desktop_test.go new file mode 100644 index 0000000..77a237f --- /dev/null +++ b/internal/webui/desktop_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +package webui + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +type blockingLocalHandler struct { + started chan<- struct{} + release <-chan struct{} +} + +func (handler blockingLocalHandler) ServeHTTP(response http.ResponseWriter, _ *http.Request) { + close(handler.started) + <-handler.release + response.WriteHeader(http.StatusNoContent) +} + +func TestInProcessHandlerReplacesWithoutBlockingNewRequests(t *testing.T) { + t.Parallel() + started := make(chan struct{}) + release := make(chan struct{}) + handler := NewInProcessHandler(blockingLocalHandler{started: started, release: release}) + served := make(chan struct{}) + go func() { + handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "wails://wails/", nil)) + close(served) + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("initial in-process request did not start") + } + drained := handler.Replace(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusNoContent) + })) + select { + case <-drained: + t.Fatal("previous session drained while its request was in flight") + case <-time.After(20 * time.Millisecond): + } + fresh := httptest.NewRecorder() + handler.ServeHTTP(fresh, httptest.NewRequest(http.MethodGet, "wails://wails/", nil)) + if fresh.Code != http.StatusNoContent { + t.Fatalf("replacement handler status = %d, want %d", fresh.Code, http.StatusNoContent) + } + close(release) + select { + case <-served: + case <-time.After(time.Second): + t.Fatal("initial in-process request did not finish") + } + select { + case <-drained: + case <-time.After(time.Second): + t.Fatal("previous session did not drain after its request completed") + } +} diff --git a/internal/webui/server.go b/internal/webui/server.go index 1919108..f2faaf2 100644 --- a/internal/webui/server.go +++ b/internal/webui/server.go @@ -27,6 +27,10 @@ import ( const ( csrfHeader = "X-Sith-CSRF" localMode = "local" + + // DesktopOrigin is Wails' in-process macOS WebView origin. It never binds a + // TCP listener and is accepted only by the native desktop host. + DesktopOrigin = "wails://wails" ) //go:embed assets/* @@ -85,11 +89,16 @@ func New(ctx context.Context, store *fleetcache.Store, syncer Syncer, local loca }, nil } -// Handler returns the hardened frontend/API handler for one exact listener URL. +// Handler returns the hardened frontend/API handler for one exact local origin. func (application *Application) Handler(baseURL string) (http.Handler, error) { parsed, err := url.Parse(baseURL) - if err != nil || parsed.Scheme != "http" || parsed.Host == "" || parsed.Path != "" { - return nil, fmt.Errorf("configure web UI handler: base URL must be an http origin") + if err != nil || parsed.Host == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("configure web UI handler: base URL must be an exact local origin") + } + if baseURL != DesktopOrigin { + if parsed.Scheme != "http" || ValidateLoopbackAddress(parsed.Hostname()) != nil { + return nil, fmt.Errorf("configure web UI handler: base URL must be a loopback http origin") + } } mux := http.NewServeMux() mux.HandleFunc("GET /", application.serveIndex) diff --git a/internal/webui/server_test.go b/internal/webui/server_test.go index 73ac730..77517ce 100644 --- a/internal/webui/server_test.go +++ b/internal/webui/server_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "net/http" "net/http/httptest" "slices" @@ -88,6 +89,54 @@ func TestHandlerEnforcesHostOriginCapabilityAndSecurityHeaders(t *testing.T) { } } +func TestHandlerAllowsOnlyTheExplicitDesktopOrigin(t *testing.T) { + t.Parallel() + application := testApplication(t) + handler, err := application.Handler(DesktopOrigin) + if err != nil { + t.Fatalf("Handler(%q) error = %v", DesktopOrigin, err) + } + request := httptest.NewRequest(http.MethodGet, DesktopOrigin+"/api/v1/meta", nil) + request.Host = "wails" + request.Header.Set(csrfHeader, application.token) + request.Header.Set("Origin", DesktopOrigin) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("desktop meta status/body = %d/%s", recorder.Code, recorder.Body.String()) + } + for _, origin := range []string{"https://wails.localhost", "http://example.com", "wails://attacker"} { + if _, err := application.Handler(origin); err == nil { + t.Errorf("Handler(%q) error = nil", origin) + } + } +} + +func TestDesktopFolderBridgeIsOptInAndDoesNotExposePaths(t *testing.T) { + t.Parallel() + index, err := fs.ReadFile(embeddedAssets, "assets/index.html") + if err != nil { + t.Fatal(err) + } + script, err := fs.ReadFile(embeddedAssets, "assets/app.js") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(index), `id="import-folder-button" class="quiet-action" type="button" hidden`) { + t.Fatal("desktop import control is not hidden by default") + } + if !strings.Contains(string(script), "window.go?.cli?.DesktopBridge?.ChooseKubeconfigDirectory") || + !strings.Contains(string(script), "if (await directoryPicker()) window.location.reload()") { + t.Fatal("desktop bridge is not opt-in or does not reload after a successful source swap") + } + if !strings.Contains(string(script), `snapshot.last_error === desktopHydrationFailure`) { + t.Fatal("desktop UI does not allowlist a sanitized hydration failure") + } + if strings.Contains(string(script), "selectedDirectory") || strings.Contains(string(script), "kubeconfigDir") { + t.Fatal("desktop bridge must not retain a selected local path in the UI") + } +} + func TestSnapshotReadsCacheOnlyAndRefreshIsExplicit(t *testing.T) { t.Parallel() syncer := &webSyncer{} @@ -122,6 +171,27 @@ func TestSnapshotReadsCacheOnlyAndRefreshIsExplicit(t *testing.T) { } } +func TestSnapshotExposesOnlySafeImportMetadata(t *testing.T) { + t.Parallel() + store := populatedWebStore(t) + store.SetDiscovery(fleet.LocalWorkspace, connector.Discovery{ + Scopes: []connector.Scope{{ + Name: "import-123/context/prod", DisplayName: "prod", Origin: "team-a.yaml", Reachable: true, ObservedAt: time.Now().UTC(), + }}, + Diagnostics: []connector.Diagnostic{{Source: "broken.yaml", Message: "invalid kubeconfig"}}, + }) + application, err := New(t.Context(), store, &webSyncer{}, &webLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = application.Close() }) + response := serve(testHandler(t, application), http.MethodGet, "/api/v1/snapshot", application.token, nil) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "team-a.yaml") || + !strings.Contains(response.Body.String(), "broken.yaml") || strings.Contains(response.Body.String(), "/Users/") { + t.Fatalf("snapshot status/body = %d/%s", response.Code, response.Body.String()) + } +} + func TestRefreshRequestsAreSingleFlight(t *testing.T) { t.Parallel() syncer := &blockingWebSyncer{started: make(chan struct{}), release: make(chan struct{})} diff --git a/sessions/2026-07-14-f11-directory-entry-cap-cleanup.md b/sessions/2026-07-14-f11-directory-entry-cap-cleanup.md new file mode 100644 index 0000000..20153c9 --- /dev/null +++ b/sessions/2026-07-14-f11-directory-entry-cap-cleanup.md @@ -0,0 +1,22 @@ +# Session — 2026-07-14 — F11 directory entry-cap cleanup + +**Builder:** Gnani Rahul · **Model/effort:** Codex / autonomous implementation · **Branch:** `gnanirahulnutakki/fix/f11-directory-entry-cap` +**Slice(s):** F11.7 / #162 · **Status:** validated; final cleanup PR pending + +--- + +[G] Goal: Remove the one redundant regular-file counter discovered by the completed peer review of PR #164, leaving a single auditable directory-entry bound. + +[S] Scope: `internal/connector/kubeconfig/directory.go` only. Out: changes to import behavior, user-visible limits, diagnostics, source grouping, tests, or product documentation. + +[A] Action: Removed the candidate counter and its unreachable limit branch. Every non-root directory traversal entry remains counted before type-specific handling, so the existing 128-entry rejection and safe `kubeconfig entry limit reached` diagnostic are unchanged. + +[R] Review: CodeRabbit's completed review of #164 correctly identified the candidate check as unreachable because `entries` is incremented first and candidates are a strict subset. The cleanup implements that feedback directly. The reviewer’s session-journal scope warning is not actionable: the repository workflow requires session journals, and this record provides the review/validation evidence without changing product behavior. + +[T] Test: Exact-source validation passed: `go test -race -count=1 ./internal/connector/kubeconfig`; `make ci`; `make e2e-isolation` including the 50,000x cross-workspace fuzz campaign; `make release-check` with two reproducible four-platform archive/SBOM passes; and `KIND=/Volumes/EXTENDED/MacData/tools/bin/kind make e2e-kind` in 153.155 seconds. Temporary kind clusters were deleted by the harness. + +[C] Continuity: PR #164 merged to `dev` as `5061c4f44e802568c3d2929f36e9d3c1ba0ab211`; exact post-merge CI `29375898259` and CodeQL `29375897905` passed. Keep #162 open until this final cleanup PR is green, merged, and verified on the resulting `dev` commit. + +--- + +**Session close:** ready for signed/DCO/GSTACK commit and final review. diff --git a/sessions/2026-07-14-f11-directory-import-review-fixes.md b/sessions/2026-07-14-f11-directory-import-review-fixes.md new file mode 100644 index 0000000..f88e791 --- /dev/null +++ b/sessions/2026-07-14-f11-directory-import-review-fixes.md @@ -0,0 +1,22 @@ +# Session — 2026-07-14 — F11 directory-import review corrections + +**Builder:** Gnani Rahul · **Model/effort:** Codex / autonomous implementation · **Branch:** `gnanirahulnutakki/fix/f11-directory-import-review` +**Slice(s):** F11.7 / #162 · **Status:** validated; corrective PR pending + +--- + +[G] Goal: Close the review gaps discovered after F11.7 directory-import PR #163 merged, without broadening the local-only product scope. + +[S] Scope: the four valid CodeRabbit review findings against #163: total directory-walk bounds, root-error privacy, multi-context source-filter coverage, and duplicate e2e UI startup logic. Out: changes to generic cache contracts, native app packaging, kubeconfig persistence, or network behavior. + +[A] Action: Cap every non-root traversed filesystem entry before its type is inspected, preserving the regular-file cap and safe diagnostics; remove path-bearing root traversal errors; retain a two-context `first.yaml` fixture plus a nested second source and assert the comma-separated source filter returns both selected contexts' records; and share the e2e web UI process bootstrap helper. README now accurately states that the 128-entry bound includes ignored symlinks and directories. + +[R] Review: The late CodeRabbit review of #163 also reported a docstring-coverage threshold warning from its own configuration. It is not a repository gate and does not identify a missing public API contract. The four concrete findings were verified as valid and corrected here. + +[T] Test: Exact-source validation passed: targeted `go test -race -count=1 ./internal/connector/kubeconfig ./internal/cli ./internal/webui`; `make ci`; `make e2e-isolation` including the 50,000x cross-workspace fuzz campaign; `make release-check` with two reproducible four-platform archive/SBOM passes; and `KIND=/Volumes/EXTENDED/MacData/tools/bin/kind make e2e-kind` in 154.553 seconds. Temporary kind clusters were deleted by the test harness; `kind get clusters` reports none. + +[C] Continuity: #163 merged to `dev` as `9a6c0e99cd07b626201d9504e1ab4179b54d692a`; its exact post-merge CI run `29373871098` and CodeQL run `29373870789` both passed. Dependabot, code-scanning, and secret-scanning queues were `0 / 0 / 0` after that merge. Keep #162 open until this corrective PR is merged and its own exact `dev` checks pass. + +--- + +**Session close:** ready for signed/DCO/GSTACK commit and peer review. diff --git a/sessions/2026-07-14-f11-kubeconfig-directory-import.md b/sessions/2026-07-14-f11-kubeconfig-directory-import.md new file mode 100644 index 0000000..584a30c --- /dev/null +++ b/sessions/2026-07-14-f11-kubeconfig-directory-import.md @@ -0,0 +1,22 @@ +# Session — 2026-07-14 — F11 kubeconfig directory import + +**Builder:** Gnani Rahul · **Model/effort:** Codex / autonomous implementation · **Branch:** `gnanirahulnutakki/feat/f11-kubeconfig-directory-import` +**Slice(s):** F11.7 / #162 · **Status:** ready-for-review + +--- + +[G] Goal: Let the loopback local fleet IDE import a selected directory of kubeconfig files and group its contexts without compromising the no-account, no-telemetry, credential-local posture. + +[S] Scope: `sith ui --kubeconfig-dir` only; bounded in-memory directory import, source-grouped UI contexts, safe diagnostics, unit/race tests, and the existing real two-kind UI gate. Out: a native desktop wrapper, browser filesystem picker, kubeconfig writes, lazy source activation, and claims of complete Lens feature parity. + +[A] Action: Added a safe directory loader that rejects unsafe roots, ignores symlinks, bounds the walk, parses files independently with client-go-compatible configuration semantics, namespaces imported context/cluster/user identities deterministically, and retains only relative source labels plus generic diagnostics in the cache/UI model. Wired the flag into `sith ui`; source selection filters the existing cache and local operations retain their direct user-identity Kubernetes path. + +[T] Test: Targeted race suites passed after every review correction. Final exact-source gates passed: `make ci` (format, lint, vet, govulncheck, full race/egress/policy suite, binary build); `make e2e-isolation` (forced PostgreSQL RLS and 50,000x cross-workspace fuzz); `KIND=/Volumes/EXTENDED/MacData/tools/bin/kind make e2e-kind` (real two-kind fan-out plus OCI contract in 160.782 seconds); and `make release-check` (two reproducible four-platform archive/SBOM passes plus formula generation). + +[C] Checkpoint #1: signed/DCO/GSTACK F11.7 implementation checkpoint. CodeRabbit review completed with two resolved major and three resolved minor findings; one generic-cache sanitization suggestion was declined because it would mask a connector-contract violation already prevented and tested at the directory-import boundary. All final gates and cleanup/security queue checks are green; the immutable implementation SHA is recorded by the follow-up journal checkpoint before PR creation. + +[C] Checkpoint #2: `43f0118010c4c0f7ded80d05e2490b87eb50e24d` — signed/DCO/GSTACK implementation commit verified locally with GitHub-recognized SSH identity. Next: push PR #162 into `dev`, require hosted CI and CodeRabbit review, merge, then verify exact post-merge CI and queues. + +--- + +**Session close:** ready for review · **Open questions touched:** none; the existing aggregation-first startup model remains the safe default, so selection filters an already independently probed source rather than delaying fleet discovery. diff --git a/sessions/2026-07-14-f11-native-desktop-shell.md b/sessions/2026-07-14-f11-native-desktop-shell.md new file mode 100644 index 0000000..64d248c --- /dev/null +++ b/sessions/2026-07-14-f11-native-desktop-shell.md @@ -0,0 +1,104 @@ +# F11.8 native local desktop shell + +Issue: [#166](https://github.com/ArdurAI/sith/issues/166) + +Branch: `gnanirahulnutakki/feat/f11-native-desktop-shell` + +Base: `origin/dev` at `c6fa47bb63a269025bfcb3ab9f8042bb02d71edb` + +## [G] Goal + +Add the first native macOS form of Sith's local fleet IDE without creating a +second Kubernetes client, a TCP listener, account state, telemetry, or a path +leak across the UI bridge. + +## [D] Decision + +- ADR 0010 adopts stable Wails v2 as a thin native shell; Wails v3 remains + alpha and is not selected. +- The native WebView serves the existing `webui.Application` at the exact + `wails://wails` origin through Wails' in-process asset-server middleware. +- The only native bridge method opens a directory chooser. It returns success, + cancellation, or a sanitized failure category only; the chosen path and + kubeconfig contents never enter JavaScript, diagnostics, or persistent state. +- A choice creates a bounded kubeconfig-import source and complete replacement + in-memory session before atomically replacing the active handler. Failure + retains the active session. +- A replacement switches routing immediately. Requests already leased to the + prior session drain before it closes, so one slow request cannot block the + new fleet view. +- `make desktop-build` creates an Apple Silicon development bundle with stable + `com.ardurai.sith` identity and an ad-hoc signature. Developer ID signing, + notarization, stapling, and release provenance remain E9 follow-up work. + +## [A] Red-team review + +- The source passes explicit `wails://wails` only to the existing hardened + Host/Origin/CSRF/CSP handler; all other non-loopback origins remain rejected. +- `InProcessHandler` leases the selected session briefly under a mutex, then + releases the routing lock before invoking it. Replacement routes new requests + immediately and closes the prior application only after its leased requests drain. +- `sith desktop --kubeconfig-dir` constructs the bounded directory source + before the desktop host, avoiding default-kubeconfig hydration before the + explicit import validates. +- Browser mode has no `window.go` bridge, so the import control remains hidden. +- CodeRabbit's initial staged review found a major close/import race and a + minor error-wrapping suggestion. The major race is fixed with terminal host + state and a deterministic regression test. The minor is intentionally not + applied: native dialog and kubeconfig errors can contain local paths, so the + bridge and CLI return stable redacted categories. +- The explicit hosted CodeRabbit review on PR #167 then found valid packaging, + dependency-override, non-starving session-handoff, hydration-status, native + signal-shutdown, and documentation gaps. All are fixed in the review + checkpoint with focused regression tests. The tool's suggested `-nomodsync` + spelling was verified against Wails v2.12.0 and corrected to its actual + `-nosyncgomod` flag. + +## [T] Tests and evidence + +- Focused `go test -race -count=1 ./internal/cli ./internal/webui`: PASS. +- Replacement-preserves-session and in-flight-handler-replacement tests: PASS; + the pair is stable across 50 local repetitions. +- Final `make ci`: PASS (format, vet, lint, reachable-vulnerability scan with + no findings, race tests, safety scripts, performance, binary e2e in 18.265s, + and production build). +- Final `make e2e-isolation`: PASS (forced PostgreSQL RLS tests and 50,000-case + cross-workspace selector fuzz campaign). +- Final `make release-check`: PASS (two verified Darwin/Linux amd64/arm64 + snapshots, SPDX SBOMs, formula rendering, and deterministic digests). +- Final `make e2e-kind`: PASS in 158.742 seconds for real two-cluster fleet + fanout and OCI image contracts. `kind get clusters` and the Sith-named Docker + container check were empty afterward. +- Final `make desktop-build WAILS=/Volumes/EXTENDED/MacData/go/bin/wails`: + PASS with Wails CLI v2.12.0. The resulting app is ARM64, bundle identifier + `com.ardurai.sith`, `Signature=adhoc`, and `TeamIdentifier=not set`. +- `go run ./cmd/sith desktop --help`: PASS with the expected desktop and + `--kubeconfig-dir` contract. +- `git diff --check`: PASS before review/staging. + +## [T] Review checkpoint evidence + +- PR #167 initial hosted CI: PASS (`build · vet · gofmt · lint · test · e2e` + in 10m46s; reproducible archives/SBOM/formula in 5m9s; all CodeQL analyses + and the explicit CodeRabbit review completed). +- Review-fix final `make ci`: PASS (binary e2e 24.797s and production build). +- Review-fix `make e2e-isolation`, `make release-check`, and `make e2e-kind`: + PASS; the final real kind suite took 154.406s and cleaned every temporary + cluster. +- Review-fix desktop bundle: Wails v2.12.0, `-m -nosyncgomod`, verified ARM64, + `com.ardurai.sith`, and ad-hoc signature: PASS. The Wails build left + `go.mod` unchanged. +- The local CodeRabbit pass found an unbounded wait in the close/import test; + it is corrected with one-second bounds and idempotent source-release cleanup, + then verified by focused race tests and the final full CI run. +- Hosted Linux CI then caught the cancellation-helper test compiling against a + macOS-only definition. The helper is now shared while Wails startup remains + Darwin-only; focused race suites and the final `make ci` pass after that + correction. + +## [C] Checkpoint + +- Initial signed/DCO/GSTACK implementation checkpoint: `78af530`. +- Review-fix implementation checkpoint: `b99820e`. +- Linux-CI portability correction, fresh peer pass, signed/DCO checkpoint, + hosted CI, merge, and exact post-merge CI remain to be recorded. diff --git a/tests/e2e/kind_fanout_test.go b/tests/e2e/kind_fanout_test.go index 868f1f5..e3c8b4f 100644 --- a/tests/e2e/kind_fanout_test.go +++ b/tests/e2e/kind_fanout_test.go @@ -132,6 +132,8 @@ func TestKindFleetFanout(t *testing.T) { runCommand(ctx, t, root, "go", "build", "-trimpath", "-o", binary, "./cmd/sith") exerciseLocalOperations(ctx, t, binary, kubeconfigPath, clusterNames) exerciseWebUI(ctx, t, binary, kubeconfigPath, clusterNames) + kubeconfigDirectory := splitKindKubeconfigDirectory(t, kubeconfigPath, clusterNames) + exerciseWebUIDirectoryImport(ctx, t, binary, kubeconfigDirectory, clusterNames) exerciseMCP(ctx, t, binary, kubeconfigPath, clusterNames) command := exec.CommandContext(ctx, binary, "clusters", "--output", "json") command.Env = append(os.Environ(), "KUBECONFIG="+kubeconfigPath, "XDG_CONFIG_HOME="+t.TempDir()) @@ -743,6 +745,43 @@ func mergedKindKubeconfig(ctx context.Context, t *testing.T, kindBinary string, return path } +func splitKindKubeconfigDirectory(t *testing.T, mergedPath string, clusters []string) string { + t.Helper() + merged, err := clientcmd.LoadFromFile(mergedPath) + if err != nil { + t.Fatalf("load merged kind kubeconfig: %v", err) + } + directory := t.TempDir() + first := clientcmdapi.NewConfig() + for _, cluster := range clusters { + contextName := "kind-" + cluster + contextConfig, exists := merged.Contexts[contextName] + if !exists { + t.Fatalf("merged kubeconfig missing context %q", contextName) + } + first.Clusters[contextConfig.Cluster] = merged.Clusters[contextConfig.Cluster].DeepCopy() + first.AuthInfos[contextConfig.AuthInfo] = merged.AuthInfos[contextConfig.AuthInfo].DeepCopy() + first.Contexts[contextName] = contextConfig.DeepCopy() + first.CurrentContext = contextName + } + firstPath := filepath.Join(directory, "first.yaml") + if err := clientcmd.WriteToFile(*first, firstPath); err != nil { + t.Fatalf("write imported kind kubeconfig %q: %v", firstPath, err) + } + secondContext := "kind-" + clusters[len(clusters)-1] + contextConfig := merged.Contexts[secondContext] + second := clientcmdapi.NewConfig() + second.Clusters[contextConfig.Cluster] = merged.Clusters[contextConfig.Cluster].DeepCopy() + second.AuthInfos[contextConfig.AuthInfo] = merged.AuthInfos[contextConfig.AuthInfo].DeepCopy() + second.Contexts[secondContext] = contextConfig.DeepCopy() + second.CurrentContext = secondContext + secondPath := filepath.Join(directory, "nested", "second.yaml") + if err := clientcmd.WriteToFile(*second, secondPath); err != nil { + t.Fatalf("write imported kind kubeconfig %q: %v", secondPath, err) + } + return directory +} + func mergeConfigMaps(destination, source *clientcmdapi.Config) { for name, cluster := range source.Clusters { destination.Clusters[name] = cluster diff --git a/tests/e2e/kind_web_ui_test.go b/tests/e2e/kind_web_ui_test.go index 0ced2a7..4e37822 100644 --- a/tests/e2e/kind_web_ui_test.go +++ b/tests/e2e/kind_web_ui_test.go @@ -175,6 +175,91 @@ func exerciseWebUI( process.stop(t) } +func exerciseWebUIDirectoryImport( + ctx context.Context, + t *testing.T, + binary, kubeconfigDirectory string, + clusters []string, +) { + t.Helper() + process, origin := startWebUIFromDirectory(ctx, t, binary, kubeconfigDirectory) + t.Cleanup(func() { process.stop(t) }) + client := &http.Client{Timeout: 20 * time.Second} + index := webUIRequest(ctx, t, client, http.MethodGet, origin, "", "", nil) + match := webUITokenPattern.FindSubmatch(index.Body) + if index.StatusCode != http.StatusOK || len(match) != 2 { + process.stop(t) + t.Fatalf("directory-import web UI index status/body = %d/%q", index.StatusCode, index.Body) + } + token := string(match[1]) + deadline := time.NewTimer(45 * time.Second) + defer deadline.Stop() + var snapshot fleetcache.Snapshot + for { + response := webUIRequest(ctx, t, client, http.MethodGet, origin, "/api/v1/snapshot?kind=Pod", token, nil) + decodeWebUIJSON(t, response, http.StatusOK, &snapshot) + if snapshot.Coverage.Requested == 3 && snapshot.Coverage.Reachable == 3 && len(snapshot.Scopes) == 3 { + break + } + select { + case <-ctx.Done(): + t.Fatalf("wait for directory-import UI: %v", ctx.Err()) + case <-deadline.C: + t.Fatalf("directory-import UI did not hydrate both contexts: %#v", snapshot) + case <-time.After(250 * time.Millisecond): + } + } + if strings.Contains(mustMarshalWebUISnapshot(t, snapshot), kubeconfigDirectory) { + t.Fatalf("directory-import snapshot exposed absolute directory: %#v", snapshot) + } + byOrigin := make(map[string][]string, len(snapshot.Scopes)) + for _, scope := range snapshot.Scopes { + if scope.DisplayName == "" || scope.Origin == "" || scope.Name == scope.DisplayName { + t.Fatalf("directory-import scope metadata = %#v", scope) + } + byOrigin[scope.Origin] = append(byOrigin[scope.Origin], scope.Name) + } + if len(byOrigin) != 2 || len(byOrigin["first.yaml"]) != 2 || len(byOrigin["nested/second.yaml"]) != 1 { + t.Fatalf("directory-import source groups = %#v", byOrigin) + } + selected := byOrigin["first.yaml"] + selectedScopes := strings.Join(selected, ",") + filtered := webUIRequest( + ctx, t, client, http.MethodGet, origin, + "/api/v1/snapshot?kind=Pod&scopes="+url.QueryEscape(selectedScopes), token, nil, + ) + var oneSource fleetcache.Snapshot + decodeWebUIJSON(t, filtered, http.StatusOK, &oneSource) + if len(oneSource.Scopes) != 2 || oneSource.Coverage.Requested != 2 || oneSource.Coverage.Reachable != 2 { + t.Fatalf("directory-import source selection = %#v", oneSource) + } + allowed := make(map[string]struct{}, len(selected)) + for _, scope := range selected { + allowed[scope] = struct{}{} + } + observed := make(map[string]struct{}, len(selected)) + for _, record := range oneSource.Records { + if _, ok := allowed[record.Cluster]; !ok { + t.Fatalf("directory-import selection returned another source: %#v", oneSource.Records) + } + observed[record.Cluster] = struct{}{} + } + if len(observed) != len(allowed) { + t.Fatalf("directory-import selection omitted selected contexts: %#v", oneSource.Records) + } + objectPath := webUITargetPath("/api/v1/object", localops.Target{ + Context: selected[0], Namespace: "default", Kind: "Pod", Name: "sith-local-ops", + }) + object := webUIRequest(ctx, t, client, http.MethodGet, origin, objectPath, token, nil) + var viewed webUIObject + decodeWebUIJSON(t, object, http.StatusOK, &viewed) + if viewed.Target.Context != selected[0] || !strings.Contains(viewed.YAML, "sith-local-ops") || + !strings.Contains(viewed.YAML, "kind-"+clusters[0]) { + t.Fatalf("directory-import object read = %#v/%q", viewed.Target, viewed.YAML) + } + process.stop(t) +} + func exerciseWebUIEdit( ctx context.Context, t *testing.T, @@ -269,6 +354,18 @@ func startWebUI(ctx context.Context, t *testing.T, binary, kubeconfigPath string "KUBECONFIG="+kubeconfigPath, "XDG_CONFIG_HOME="+filepath.Join(filepath.Dir(kubeconfigPath), "config-home-web"), ) + return startWebUIProcess(ctx, t, command) +} + +func startWebUIFromDirectory(ctx context.Context, t *testing.T, binary, kubeconfigDirectory string) (*webUIProcess, string) { + t.Helper() + command := exec.Command(binary, "ui", "--no-open", "--address", "127.0.0.1", "--port", "0", "--kubeconfig-dir", kubeconfigDirectory) + command.Env = append(os.Environ(), "XDG_CONFIG_HOME="+filepath.Join(kubeconfigDirectory, "config-home-web")) + return startWebUIProcess(ctx, t, command) +} + +func startWebUIProcess(ctx context.Context, t *testing.T, command *exec.Cmd) (*webUIProcess, string) { + t.Helper() stdout, err := command.StdoutPipe() if err != nil { t.Fatalf("web UI stdout: %v", err) @@ -310,6 +407,15 @@ func startWebUI(ctx context.Context, t *testing.T, binary, kubeconfigPath string return nil, "" } +func mustMarshalWebUISnapshot(t *testing.T, snapshot fleetcache.Snapshot) string { + t.Helper() + payload, err := json.Marshal(snapshot) + if err != nil { + t.Fatal(err) + } + return string(payload) +} + func (process *webUIProcess) stop(t *testing.T) { t.Helper() process.once.Do(func() {