diff --git a/README.md b/README.md index ea03ffa..8f17c52 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # Sith -**Status: Slice 3 local fleet client.** The CLI discovers every context resolved by client-go, -hydrates a local in-memory fleet cache through per-context watches, serves coverage-honest fleet -search, and provides explicit-context logs, exec, port-forward, describe, and YAML view/edit. +**Status: Slice 4 local fleet client.** The CLI and embedded browser IDE discover every context +resolved by client-go, hydrate one local in-memory fleet cache through per-context watches, serve +coverage-honest fleet search/correlation, and provide explicit-context logs, exec, port-forward, +describe, and YAML view/edit. Sith is ArdurAI's single-binary, local-first Kubernetes fleet tool: **k9s for your whole fleet**. It is designed to aggregate every kubeconfig context without an account, telemetry, or cluster @@ -29,6 +30,7 @@ make build ./bin/sith exec api --context kind-dev -n apps -it -- /bin/sh ./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 ``` `sith clusters` follows standard client-go loading rules: set `KUBECONFIG` to an OS path-list or @@ -54,6 +56,15 @@ active one. The UI uses Bubble Tea v2.0.8 core only; tables and search remain local so no optional styling or 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, +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, +single-use server preview token bound to the exact target and manifest; Secret edit requires an +explicit reveal-and-edit confirmation before unredacted data enters the browser. + Local resource operations always require or derive one explicit cached context and use that context's existing kubeconfig identity directly. They are deliberately separate from Sith's governed Intent/PEP action model. Secret YAML is redacted unless `--show-secrets` is explicit; @@ -74,10 +85,11 @@ make ci ``` The real multi-cluster gate creates two temporary kind clusters with a digest-pinned node image, -checks one additional unreachable context, and proves context-isolated logs, exec, YAML/Secret -handling, describe/events, dry-run edit, and loopback TCP forwarding against a scratch fixture -image. It removes both clusters afterward. The gate requires a running Docker engine and kind -v0.32.0, and consumes additional CI time, disk, and memory: +checks one additional unreachable context, and proves CLI plus web-IDE context isolation for +search/correlation, logs, exec, YAML/Secret handling, describe/events, preview-gated edit, and +loopback TCP forwarding against a scratch fixture image. It removes both clusters afterward. The +gate requires a running Docker engine and kind v0.32.0, and consumes additional CI time, disk, +and memory: ```bash make e2e-kind diff --git a/go.mod b/go.mod index 114a2b0..af2fc79 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( k8s.io/api v0.36.2 k8s.io/apimachinery v0.36.2 k8s.io/client-go v0.36.2 + k8s.io/streaming v0.36.2 sigs.k8s.io/yaml v1.6.0 ) @@ -61,7 +62,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect - k8s.io/streaming v0.36.2 // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index a6a45cd..1ab40ed 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -179,10 +179,10 @@ func TestClustersUsesInjectedSource(t *testing.T) { } } -func TestUIStub(t *testing.T) { - stdout, _, exitCode := runCLI(t, []string{"ui"}, fleet.StubSource{}) - if exitCode != 0 || stdout != "sith ui: not yet implemented — see F11.3 (#34).\n" { - t.Fatalf("exit/stdout = %d/%q", exitCode, stdout) +func TestUIRequiresLocalBackend(t *testing.T) { + stdout, stderr, exitCode := runCLI(t, []string{"ui", "--no-open"}, fleet.StubSource{}) + if exitCode == 0 || stdout != "" || !strings.Contains(stderr, "requires a Kubernetes reader") { + t.Fatalf("exit/stdout/stderr = %d/%q/%q", exitCode, stdout, stderr) } } diff --git a/internal/cli/local_test.go b/internal/cli/local_test.go index f994f43..2574983 100644 --- a/internal/cli/local_test.go +++ b/internal/cli/local_test.go @@ -14,6 +14,7 @@ import ( "sync" "testing" + "github.com/ArdurAI/sith/internal/connector" "github.com/ArdurAI/sith/internal/fleet" "github.com/ArdurAI/sith/internal/localops" ) @@ -129,6 +130,24 @@ func TestEditSurfacesServerDryRunRejectionWithoutApply(t *testing.T) { } } +func TestUIRefusesExternalBindAndStartsOnLoopback(t *testing.T) { + reader := &cacheReader{} + client := &fakeLocalClient{} + _, stderr, exitCode := runUICLI(context.Background(), t, []string{ + "ui", "--address", "0.0.0.0", "--no-open", + }, reader, client) + if exitCode == 0 || !strings.Contains(stderr, "not loopback") { + t.Fatalf("external bind exit/stderr = %d/%q", exitCode, stderr) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + stdout, stderr, exitCode := runUICLI(ctx, t, []string{"ui", "--port", "0", "--no-open"}, reader, client) + if exitCode != 0 || stderr != "" || !strings.Contains(stdout, "sith ui listening on http://127.0.0.1:") { + t.Fatalf("loopback UI exit/stdout/stderr = %d/%q/%q", exitCode, stdout, stderr) + } +} + func runLocalCLI( t *testing.T, args []string, @@ -146,6 +165,22 @@ func runLocalCLI( return stdoutBuffer.String(), stderrBuffer.String(), exitCode } +func runUICLI( + ctx context.Context, + t *testing.T, + args []string, + reader connector.Reader, + client localops.Client, +) (stdout, stderr string, exitCode int) { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var stdoutBuffer, stderrBuffer bytes.Buffer + exitCode = executeBackendContext(ctx, args, backend{ + source: connector.AsSource(reader), reader: reader, local: client, tuiInput: strings.NewReader(""), + }, &stdoutBuffer, &stderrBuffer) + return stdoutBuffer.String(), stderrBuffer.String(), exitCode +} + type fakeLocalClient struct { mu sync.Mutex callCount int diff --git a/internal/cli/root.go b/internal/cli/root.go index 124d713..0311048 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -140,7 +140,7 @@ func newRootCommand(runtime backend, stdout, stderr io.Writer) *cobra.Command { commands := []*cobra.Command{ newVersionCommand(options), newClustersCommand(options, runtime.source), - newUICommand(), + newUICommand(runtime.reader, runtime.local), newHubCommand(), } if runtime.reader != nil { diff --git a/internal/cli/ui.go b/internal/cli/ui.go index 6cecd34..6901b23 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -3,21 +3,132 @@ package cli import ( + "context" + "errors" "fmt" + "net" + "net/http" + "os/exec" + "runtime" + "strconv" + "time" "github.com/spf13/cobra" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleetcache" + "github.com/ArdurAI/sith/internal/hydrate" + "github.com/ArdurAI/sith/internal/localops" + "github.com/ArdurAI/sith/internal/webui" ) -func newUICommand() *cobra.Command { - return &cobra.Command{ +type uiOptions struct { + address string + port int + noOpen bool +} + +func newUICommand(reader connector.Reader, local localops.Client) *cobra.Command { + options := &uiOptions{address: "127.0.0.1"} + command := &cobra.Command{ Use: "ui", - Short: "Start the local fleet IDE", + Short: "Start the loopback-only local fleet IDE", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { - if _, err := fmt.Fprintln(command.OutOrStdout(), "sith ui: not yet implemented — see F11.3 (#34)."); err != nil { - return fmt.Errorf("write ui status: %w", err) + if reader == nil || local == nil { + return fmt.Errorf("local fleet UI requires a Kubernetes reader and local operations client") } - return nil + 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") + return command +} + +func runWebUI( + ctx context.Context, + command *cobra.Command, + reader connector.Reader, + local localops.Client, + options *uiOptions, +) error { + if err := webui.ValidateLoopbackAddress(options.address); err != nil { + return err + } + if options.port < 0 || options.port > 65535 { + return fmt.Errorf("local web UI port must be between 0 and 65535") + } + listener, err := net.Listen("tcp", net.JoinHostPort(options.address, strconv.Itoa(options.port))) + if err != nil { + return fmt.Errorf("listen for local fleet UI: %w", err) + } + defer func() { _ = listener.Close() }() + tcpAddress, ok := listener.Addr().(*net.TCPAddr) + if !ok { + return fmt.Errorf("local fleet UI listener returned an unexpected address type") + } + origin := "http://" + net.JoinHostPort(options.address, strconv.Itoa(tcpAddress.Port)) + store := fleetcache.New() + hydrator, err := hydrate.New(reader, store) + if err != nil { + return err + } + application, err := webui.New(ctx, store, hydrator, local) + if err != nil { + return err + } + defer func() { _ = application.Close() }() + handler, err := application.Handler(origin) + if err != nil { + return err + } + server := &http.Server{ + Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 0, + IdleTimeout: 2 * time.Minute, MaxHeaderBytes: 32 << 10, + } + serverErrors := make(chan error, 1) + go func() { serverErrors <- server.Serve(listener) }() + go func() { _ = hydrator.Run(ctx) }() + if _, err := fmt.Fprintf(command.OutOrStdout(), "sith ui listening on %s\n", origin); err != nil { + return fmt.Errorf("write local fleet UI address: %w", err) + } + if !options.noOpen { + go func() { + if err := openBrowser(origin); err != nil { + _, _ = fmt.Fprintf(command.ErrOrStderr(), "warning: open browser: %v\n", err) + } + }() + } + var serveErr error + select { + case <-ctx.Done(): + case serveErr = <-serverErrors: + } + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + shutdownErr := server.Shutdown(shutdownCtx) + if errors.Is(serveErr, http.ErrServerClosed) { + serveErr = nil + } + return errors.Join(serveErr, shutdownErr) +} + +func openBrowser(url string) error { + var name string + var arguments []string + switch runtime.GOOS { + case "darwin": + name, arguments = "open", []string{url} + case "windows": + name, arguments = "rundll32", []string{"url.dll,FileProtocolHandler", url} + default: + name, arguments = "xdg-open", []string{url} + } + // #nosec G204 -- executable names are fixed above and the URL is a generated loopback origin. + if err := exec.Command(name, arguments...).Start(); err != nil { + return err + } + return nil } diff --git a/internal/webui/api.go b/internal/webui/api.go new file mode 100644 index 0000000..ebc6929 --- /dev/null +++ b/internal/webui/api.go @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: Apache-2.0 + +package webui + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/pmezard/go-difflib/difflib" + + "github.com/ArdurAI/sith/internal/fleetcache" + "github.com/ArdurAI/sith/internal/hydrate" + "github.com/ArdurAI/sith/internal/localops" +) + +const ( + maxAPIRequestBytes = 10 << 20 + maxExecOutputBytes = 1 << 20 + maxExecArguments = 64 + maxForwardPorts = 16 +) + +func (application *Application) registerAPI(mux *http.ServeMux) { + mux.HandleFunc("GET /api/v1/meta", application.handleMeta) + mux.HandleFunc("GET /api/v1/snapshot", application.handleSnapshot) + mux.HandleFunc("POST /api/v1/sync", application.handleSync) + mux.HandleFunc("GET /api/v1/object", application.handleObject) + mux.HandleFunc("GET /api/v1/logs", application.handleLogs) + mux.HandleFunc("POST /api/v1/exec", application.handleExec) + mux.HandleFunc("POST /api/v1/edit/preview", application.handleEditPreview) + mux.HandleFunc("POST /api/v1/edit/apply", application.handleEditApply) + mux.HandleFunc("GET /api/v1/port-forwards", application.handleListForwards) + mux.HandleFunc("POST /api/v1/port-forwards", application.handleStartForward) + mux.HandleFunc("DELETE /api/v1/port-forwards/{id}", application.handleDeleteForward) +} + +func (application *Application) handleMeta(response http.ResponseWriter, _ *http.Request) { + writeJSON(response, http.StatusOK, map[string]any{ + "mode": application.mode, "account_required": false, "telemetry": false, + "lenses": hydrate.TierOneKinds(), + "operations": []string{"describe", "yaml", "logs", "exec", "port-forward", "edit"}, + }) +} + +func (application *Application) handleSnapshot(response http.ResponseWriter, request *http.Request) { + query := fleetcache.Query{ + Kind: request.URL.Query().Get("kind"), + Limit: 5000, + } + if scopes := splitNonEmpty(request.URL.Query().Get("scopes"), ","); len(scopes) > 0 { + query.Scopes = scopes + } + expression := strings.TrimSpace(request.URL.Query().Get("q")) + if expression != "" { + var parsed fleetcache.Query + var err error + if request.URL.Query().Get("correlate") == "true" { + parsed, err = fleetcache.ParseCorrelation(expression) + } else { + parsed, err = fleetcache.ParseSearch(expression) + } + if err != nil { + writeAPIError(response, http.StatusBadRequest, err.Error()) + return + } + if request.URL.Query().Get("all") != "true" && parsed.Kind == "" { + parsed.Kind = query.Kind + } + if len(query.Scopes) > 0 { + parsed.Scopes = query.Scopes + } + parsed.Limit = query.Limit + query = parsed + } + writeJSON(response, http.StatusOK, application.store.Query(query)) +} + +type syncRequest struct { + Kinds []string `json:"kinds"` +} + +func (application *Application) handleSync(response http.ResponseWriter, request *http.Request) { + payload := syncRequest{} + if request.ContentLength != 0 { + if err := decodeJSON(response, request, 64<<10, &payload); err != nil { + writeAPIError(response, http.StatusBadRequest, err.Error()) + return + } + } + kinds := append([]string(nil), payload.Kinds...) + if !application.refreshing.CompareAndSwap(false, true) { + writeJSON(response, http.StatusAccepted, map[string]string{"status": "refresh already running"}) + return + } + go func() { + defer application.refreshing.Store(false) + if len(kinds) == 0 { + _ = application.syncer.SyncOnce(application.ctx) + return + } + _ = application.syncer.SyncKinds(application.ctx, kinds...) + }() + writeJSON(response, http.StatusAccepted, map[string]string{"status": "refresh scheduled"}) +} + +type objectResponse struct { + Target localops.Target `json:"target"` + YAML string `json:"yaml"` + Events []json.RawMessage `json:"events"` +} + +func (application *Application) handleObject(response http.ResponseWriter, request *http.Request) { + target, err := targetFromQuery(request) + if err != nil { + writeAPIError(response, http.StatusBadRequest, err.Error()) + return + } + if request.URL.Query().Get("describe") == "true" { + description, err := application.local.Describe(request.Context(), target) + if err != nil { + writeOperationError(response, err) + return + } + events := make([]json.RawMessage, 0, len(description.Events)) + for _, event := range description.Events { + events = append(events, append(json.RawMessage(nil), event.Observed...)) + } + writeJSON(response, http.StatusOK, objectResponse{Target: target, YAML: string(description.Object.YAML), Events: events}) + return + } + reveal := request.URL.Query().Get("reveal_secrets") == "true" + view, err := application.local.View(request.Context(), target, reveal) + if err != nil { + writeOperationError(response, err) + return + } + writeJSON(response, http.StatusOK, objectResponse{Target: target, YAML: string(view.YAML), Events: []json.RawMessage{}}) +} + +func (application *Application) handleLogs(response http.ResponseWriter, request *http.Request) { + target, err := targetFromQuery(request) + if err != nil { + writeAPIError(response, http.StatusBadRequest, err.Error()) + return + } + target.Kind = "Pod" + tail := int64(200) + if value := request.URL.Query().Get("tail"); value != "" { + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed < -1 || parsed > 10000 { + writeAPIError(response, http.StatusBadRequest, "tail must be between -1 and 10000") + return + } + tail = parsed + } + since := time.Duration(0) + if value := request.URL.Query().Get("since"); value != "" { + since, err = time.ParseDuration(value) + if err != nil || since < 0 { + writeAPIError(response, http.StatusBadRequest, "since must be a non-negative duration") + return + } + } + stream, err := application.local.Logs(request.Context(), target, localops.LogOptions{ + Container: request.URL.Query().Get("container"), Follow: request.URL.Query().Get("follow") == "true", + Previous: request.URL.Query().Get("previous") == "true", Timestamps: true, TailLines: &tail, Since: since, + }) + if err != nil { + writeOperationError(response, err) + return + } + defer func() { _ = stream.Close() }() + response.Header().Set("Content-Type", "text/plain; charset=utf-8") + response.WriteHeader(http.StatusOK) + flusher, _ := response.(http.Flusher) + buffer := make([]byte, 16<<10) + for { + count, readErr := stream.Read(buffer) + if count > 0 { + if _, err := response.Write(buffer[:count]); err != nil { + return + } + if flusher != nil { + flusher.Flush() + } + } + if readErr != nil { + return + } + } +} + +type execRequest struct { + Target localops.Target `json:"target"` + Container string `json:"container,omitempty"` + Command []string `json:"command"` +} + +type execResponse struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Truncated bool `json:"truncated"` +} + +func (application *Application) handleExec(response http.ResponseWriter, request *http.Request) { + payload := execRequest{} + if err := decodeJSON(response, request, maxAPIRequestBytes, &payload); err != nil { + writeAPIError(response, http.StatusBadRequest, err.Error()) + return + } + if len(payload.Command) == 0 || len(payload.Command) > maxExecArguments { + writeAPIError(response, http.StatusBadRequest, "command must contain between 1 and 64 arguments") + return + } + for _, argument := range payload.Command { + if len(argument) > 4096 { + writeAPIError(response, http.StatusBadRequest, "each command argument must be at most 4096 bytes") + return + } + } + payload.Target.Kind = "Pod" + stdout, stderr := &boundedWriter{limit: maxExecOutputBytes}, &boundedWriter{limit: maxExecOutputBytes} + err := application.local.Exec(request.Context(), payload.Target, localops.ExecOptions{ + Container: payload.Container, Command: append([]string(nil), payload.Command...), + }, localops.Streams{Stdout: stdout, Stderr: stderr}) + if err != nil { + writeOperationError(response, err) + return + } + writeJSON(response, http.StatusOK, execResponse{ + Stdout: stdout.String(), Stderr: stderr.String(), Truncated: stdout.truncated || stderr.truncated, + }) +} + +type editRequest struct { + Target localops.Target `json:"target"` + Manifest string `json:"manifest"` + PreviewToken string `json:"preview_token,omitempty"` +} + +type editPreviewResponse struct { + Current string `json:"current"` + DryRun string `json:"dry_run"` + Diff string `json:"diff"` + PreviewToken string `json:"preview_token"` +} + +func (application *Application) handleEditPreview(response http.ResponseWriter, request *http.Request) { + payload, ok := decodeEditRequest(response, request) + if !ok { + return + } + preview, err := application.local.PreviewApply(request.Context(), payload.Target, []byte(payload.Manifest)) + if err != nil { + writeOperationError(response, err) + return + } + diff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: difflib.SplitLines(string(preview.CurrentYAML)), B: difflib.SplitLines(string(preview.DryRunYAML)), + FromFile: payload.Target.Kind + "/" + payload.Target.Name + " (current)", + ToFile: payload.Target.Kind + "/" + payload.Target.Name + " (server dry-run)", Context: 3, + }) + if err != nil { + writeAPIError(response, http.StatusInternalServerError, "build server dry-run diff: "+err.Error()) + return + } + token, err := application.previews.issue(payload.Target, payload.Manifest) + if err != nil { + writeAPIError(response, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(response, http.StatusOK, editPreviewResponse{ + Current: string(preview.CurrentYAML), DryRun: string(preview.DryRunYAML), Diff: diff, PreviewToken: token, + }) +} + +func (application *Application) handleEditApply(response http.ResponseWriter, request *http.Request) { + payload, ok := decodeEditRequest(response, request) + if !ok { + return + } + if err := application.previews.consume(payload.PreviewToken, payload.Target, payload.Manifest); err != nil { + writeAPIError(response, http.StatusConflict, err.Error()) + return + } + evidence, err := application.local.Apply(request.Context(), payload.Target, []byte(payload.Manifest)) + if err != nil { + writeOperationError(response, err) + return + } + writeJSON(response, http.StatusOK, evidence) +} + +func decodeEditRequest(response http.ResponseWriter, request *http.Request) (editRequest, bool) { + payload := editRequest{} + if err := decodeJSON(response, request, maxAPIRequestBytes, &payload); err != nil { + writeAPIError(response, http.StatusBadRequest, err.Error()) + return editRequest{}, false + } + if len(payload.Manifest) == 0 || len(payload.Manifest) > maxAPIRequestBytes { + writeAPIError(response, http.StatusBadRequest, "manifest must contain between 1 byte and 10 MiB") + return editRequest{}, false + } + return payload, true +} + +type startForwardRequest struct { + Target localops.Target `json:"target"` + Ports []string `json:"ports"` +} + +func (application *Application) handleListForwards(response http.ResponseWriter, _ *http.Request) { + writeJSON(response, http.StatusOK, application.forwards.list()) +} + +func (application *Application) handleStartForward(response http.ResponseWriter, request *http.Request) { + payload := startForwardRequest{} + if err := decodeJSON(response, request, 64<<10, &payload); err != nil { + writeAPIError(response, http.StatusBadRequest, err.Error()) + return + } + if len(payload.Ports) == 0 || len(payload.Ports) > maxForwardPorts { + writeAPIError(response, http.StatusBadRequest, "ports must contain between 1 and 16 mappings") + return + } + if err := application.forwards.reserve(); err != nil { + writeAPIError(response, http.StatusTooManyRequests, err.Error()) + return + } + reserved := true + defer func() { + if reserved { + application.forwards.releaseReservation() + } + }() + session, err := application.local.PortForward(application.ctx, localops.ForwardRequest{ + Target: payload.Target, Ports: append([]string(nil), payload.Ports...), + }) + if err != nil { + writeOperationError(response, err) + return + } + timer := time.NewTimer(20 * time.Second) + defer timer.Stop() + select { + case <-session.Ready(): + forward, err := application.forwards.add(payload.Target, session) + if err != nil { + _ = session.Close() + writeOperationError(response, err) + return + } + reserved = false + writeJSON(response, http.StatusCreated, forward) + case err := <-session.Done(): + _ = session.Close() + if err == nil { + err = fmt.Errorf("port-forward ended before becoming ready") + } + writeOperationError(response, err) + case <-timer.C: + _ = session.Close() + writeAPIError(response, http.StatusGatewayTimeout, "port-forward did not become ready within 20 seconds") + case <-request.Context().Done(): + _ = session.Close() + } +} + +func (application *Application) handleDeleteForward(response http.ResponseWriter, request *http.Request) { + if err := application.forwards.close(request.PathValue("id")); err != nil { + writeAPIError(response, http.StatusNotFound, err.Error()) + return + } + response.WriteHeader(http.StatusNoContent) +} + +func targetFromQuery(request *http.Request) (localops.Target, error) { + target := localops.Target{ + Context: request.URL.Query().Get("context"), Namespace: request.URL.Query().Get("namespace"), + Kind: request.URL.Query().Get("kind"), Name: request.URL.Query().Get("name"), + } + if err := target.Validate(); err != nil { + return localops.Target{}, err + } + return target, nil +} + +func splitNonEmpty(value, separator string) []string { + parts := strings.Split(value, separator) + result := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +func writeOperationError(response http.ResponseWriter, err error) { + writeAPIError(response, http.StatusUnprocessableEntity, err.Error()) +} + +type boundedWriter struct { + buffer bytes.Buffer + limit int + truncated bool +} + +func (writer *boundedWriter) Write(payload []byte) (int, error) { + original := len(payload) + remaining := writer.limit - writer.buffer.Len() + if remaining <= 0 { + writer.truncated = writer.truncated || original > 0 + return original, nil + } + if len(payload) > remaining { + payload = payload[:remaining] + writer.truncated = true + } + _, _ = writer.buffer.Write(payload) + return original, nil +} + +func (writer *boundedWriter) String() string { return writer.buffer.String() } + +var _ io.Writer = (*boundedWriter)(nil) diff --git a/internal/webui/assets/app.css b/internal/webui/assets/app.css new file mode 100644 index 0000000..71e399b --- /dev/null +++ b/internal/webui/assets/app.css @@ -0,0 +1,315 @@ +:root { + --storm: #101820; + --storm-soft: #24313a; + --fog: #edf1f3; + --paper: #f8faf9; + --white: #ffffff; + --cobalt: #2155a3; + --cobalt-pale: #dce7f7; + --orange: #e56b3f; + --mint: #2d8b77; + --amber: #c58a24; + --alert: #b33a3a; + --line: #cbd4d8; + --muted: #63727b; + --display: ui-rounded, "Avenir Next", "Segoe UI", sans-serif; + --body: "Avenir Next", "Segoe UI", Helvetica, Arial, sans-serif; + --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-family: var(--body); + color: var(--storm); + background: var(--fog); + font-synthesis: none; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: + linear-gradient(90deg, rgba(16, 24, 32, 0.035) 1px, transparent 1px) 0 0 / 24px 24px, + var(--fog); +} + +button, input, textarea { font: inherit; } +button { color: inherit; } + +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +[tabindex]:focus-visible { + outline: 3px solid rgba(33, 85, 163, 0.36); + outline-offset: 2px; +} + +.boot-scan { + position: fixed; + z-index: 100; + inset: 0 0 auto; + height: 3px; + pointer-events: none; + background: var(--orange); + transform-origin: left; + animation: boot-scan 850ms cubic-bezier(.22, .9, .3, 1) both; +} + +@keyframes boot-scan { + 0% { transform: scaleX(0); opacity: 1; } + 78% { transform: scaleX(1); opacity: 1; } + 100% { transform: scaleX(1); opacity: 0; } +} + +.masthead { + display: grid; + grid-template-columns: minmax(230px, 0.8fr) minmax(320px, 1.5fr) auto; + align-items: center; + min-height: 88px; + padding: 14px 22px; + color: var(--paper); + background: var(--storm); + border-bottom: 4px solid var(--orange); +} + +.identity { display: flex; align-items: center; gap: 12px; } +.identity-mark { + display: grid; + width: 46px; + aspect-ratio: 1; + place-items: center; + border: 1px solid #52616b; + font: 700 25px/1 var(--display); + color: var(--orange); + clip-path: polygon(0 0, 82% 0, 100% 18%, 100% 100%, 0 100%); +} + +.eyebrow { + margin: 0 0 4px; + font: 700 10px/1.2 var(--mono); + letter-spacing: .16em; + text-transform: uppercase; + color: var(--muted); +} + +.masthead .eyebrow { color: #9caab2; } +h1, h2, p { margin-top: 0; } +h1 { margin-bottom: 0; font: 700 26px/1 var(--display); letter-spacing: -.02em; } +h1 span { font: 500 14px/1 var(--mono); color: #aebac0; } +h2 { margin-bottom: 0; font: 650 21px/1.15 var(--display); letter-spacing: -.02em; } + +.coverage-summary { + display: flex; + align-items: baseline; + gap: 18px; + padding: 0 24px; + border-left: 1px solid #39464f; +} + +#coverage-count { font: 650 17px/1.2 var(--display); } +#coverage-detail { color: #aebac0; font-size: 12px; } +.mast-actions { display: flex; gap: 8px; justify-content: flex-end; } + +.primary-action, .quiet-action, .mode-switch, .operation-grid button, .dialog-actions button, .close-action { + min-height: 38px; + border: 1px solid currentColor; + border-radius: 2px; + padding: 8px 12px; + font-size: 12px; + font-weight: 700; + letter-spacing: .02em; + cursor: pointer; + background: transparent; +} + +.primary-action { color: var(--storm); background: var(--orange); border-color: var(--orange); } +.quiet-action { color: var(--paper); border-color: #52616b; } +.quiet-action span { color: var(--orange); font-family: var(--mono); } +.primary-action:hover { background: #f27a4f; } +.quiet-action:hover { border-color: var(--paper); } + +.query-deck { + display: grid; + grid-template-columns: minmax(410px, 1.1fr) minmax(330px, 1fr) auto; + gap: 14px; + align-items: center; + padding: 12px 22px; + background: var(--paper); + border-bottom: 1px solid var(--line); +} + +.lens-switcher { display: flex; gap: 4px; overflow-x: auto; } +.lens-switcher button { + border: 0; + border-bottom: 2px solid transparent; + padding: 9px 10px 7px; + background: transparent; + color: var(--muted); + font: 700 11px/1 var(--mono); + text-transform: uppercase; + cursor: pointer; +} +.lens-switcher button[aria-current="true"] { color: var(--storm); border-color: var(--orange); } + +.search-field { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 10px; + min-height: 42px; + padding: 0 10px; + border: 1px solid var(--line); + background: var(--white); +} +.search-field span { font: 700 10px/1 var(--mono); color: var(--muted); text-transform: uppercase; } +.search-field input { min-width: 0; border: 0; outline: 0; background: transparent; color: var(--storm); } +.search-field kbd { font: 600 10px/1 var(--mono); color: var(--muted); border: 1px solid var(--line); padding: 4px 6px; } +.mode-switch { color: var(--cobalt); border-color: var(--cobalt); background: var(--white); } +.mode-switch[aria-pressed="true"] { color: var(--white); background: var(--cobalt); } + +.workspace { + display: grid; + grid-template-columns: 224px minmax(520px, 1fr) 340px; + min-height: calc(100vh - 155px); +} + +.context-rail, .inspector { background: var(--paper); } +.context-rail { padding: 22px 18px; border-right: 1px solid var(--line); } +.rail-heading, .board-heading { display: flex; align-items: flex-end; justify-content: space-between; } +.context-list { position: relative; margin: 25px 0 18px; padding-left: 0; } +.context-list::before { + content: ""; + position: absolute; + top: 18px; + bottom: 18px; + left: 13px; + width: 1px; + background: var(--line); +} +.context-node { + position: relative; + display: grid; + grid-template-columns: 27px minmax(0, 1fr); + gap: 10px; + width: 100%; + padding: 8px 4px 8px 0; + border: 0; + text-align: left; + background: transparent; + cursor: pointer; +} +.context-node::before { + content: ""; + z-index: 1; + width: 9px; + height: 9px; + margin: 4px 0 0 9px; + border: 3px solid var(--paper); + border-radius: 50%; + background: var(--mint); + box-shadow: 0 0 0 1px var(--mint); +} +.context-node[data-state="stale"]::before { background: var(--amber); box-shadow: 0 0 0 1px var(--amber); } +.context-node[data-state="down"]::before { background: var(--alert); box-shadow: 0 0 0 1px var(--alert); } +.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); } +.rail-note { color: var(--muted); font-size: 11px; line-height: 1.5; } + +.fleet-board { min-width: 0; padding: 22px; } +.board-heading { margin-bottom: 15px; } +.result-count { margin: 0; color: var(--muted); font: 11px/1.2 var(--mono); } +.table-frame { position: relative; min-height: 420px; overflow: auto; border: 1px solid var(--line); background: var(--white); } +table { width: 100%; border-collapse: collapse; font-size: 12px; } +thead { position: sticky; z-index: 2; top: 0; background: var(--storm); color: var(--paper); } +th { padding: 10px 12px; text-align: left; font: 650 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; } +td { max-width: 250px; padding: 10px 12px; border-bottom: 1px solid #e4e9eb; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +tbody tr { cursor: pointer; } +tbody tr:hover { background: #f1f5f7; } +tbody tr[aria-selected="true"] { background: var(--cobalt-pale); box-shadow: inset 4px 0 var(--cobalt); } +.cell-context, .cell-age, .cell-restarts { font-family: var(--mono); font-size: 10px; } +.status-pill { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; } +.status-pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--mint); } +.status-pill[data-tone="warn"]::before { background: var(--amber); } +.status-pill[data-tone="bad"]::before { background: var(--alert); } +.status-pill[data-tone="muted"]::before { background: var(--muted); } +.empty-state { display: grid; gap: 8px; place-items: center; padding: 80px 20px; color: var(--muted); text-align: center; } +.empty-state[hidden] { display: none; } +.coverage-line { padding: 11px 3px 0; color: var(--muted); font: 11px/1.4 var(--mono); } + +.inspector { padding: 22px; border-left: 1px solid var(--line); } +.inspector-empty { margin-top: 33vh; transform: translateY(-50%); } +.inspector-empty p:last-child { color: var(--muted); font-size: 12px; line-height: 1.55; } +.resource-address { margin: 7px 0 22px; color: var(--cobalt); font: 10px/1.4 var(--mono); overflow-wrap: anywhere; } +.fact-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 0 0 22px; background: var(--line); border: 1px solid var(--line); } +.fact-grid div { min-width: 0; padding: 10px; background: var(--white); } +.fact-grid dt { color: var(--muted); font: 700 9px/1 var(--mono); text-transform: uppercase; } +.fact-grid dd { margin: 5px 0 0; overflow: hidden; text-overflow: ellipsis; font-size: 12px; } +.operation-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; } +.operation-grid button { border-color: var(--line); background: var(--white); text-align: left; } +.operation-grid button:hover { border-color: var(--cobalt); color: var(--cobalt); } +.identity-boundary { margin: 18px 0 0; padding-top: 14px; border-top: 1px solid var(--line); color: var(--muted); font: 9px/1.5 var(--mono); text-transform: uppercase; } + +.action-dialog { + width: min(920px, calc(100vw - 40px)); + max-height: calc(100vh - 40px); + padding: 0; + border: 1px solid var(--storm); + border-radius: 2px; + color: var(--storm); + background: var(--paper); + box-shadow: 0 24px 80px rgba(16, 24, 32, .34); +} +.action-dialog::backdrop { background: rgba(16, 24, 32, .62); backdrop-filter: blur(2px); } +.action-dialog > header { display: flex; justify-content: space-between; align-items: center; padding: 17px 20px; border-bottom: 1px solid var(--line); } +.close-action { min-height: 32px; color: var(--muted); border-color: var(--line); } +.dialog-body { min-height: 180px; max-height: calc(100vh - 210px); overflow: auto; padding: 20px; } +.dialog-body pre { margin: 0; padding: 16px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; color: #dce7e9; background: var(--storm); font: 11px/1.55 var(--mono); tab-size: 2; } +.dialog-body textarea, .dialog-body input { + width: 100%; border: 1px solid var(--line); border-radius: 0; padding: 12px; color: var(--storm); background: var(--white); font: 12px/1.5 var(--mono); +} +.dialog-body textarea { min-height: 390px; resize: vertical; } +.dialog-body label { display: grid; gap: 7px; margin-bottom: 14px; color: var(--muted); font: 700 10px/1.3 var(--mono); text-transform: uppercase; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 13px 20px; border-top: 1px solid var(--line); } +.dialog-actions button { color: var(--cobalt); border-color: var(--cobalt); background: var(--white); } +.dialog-actions button[data-primary="true"] { color: var(--white); background: var(--cobalt); } +.event-list, .forward-list { display: grid; gap: 8px; margin-top: 15px; } +.event-entry, .forward-entry { padding: 12px; border-left: 3px solid var(--orange); background: var(--white); } +.event-entry strong, .forward-entry strong { display: block; font-size: 12px; } +.event-entry span, .forward-entry span { color: var(--muted); font-size: 11px; } +.loading-state { display: grid; min-height: 180px; place-items: center; align-content: center; gap: 12px; color: var(--muted); } +.loading-state span { width: 34px; height: 3px; background: var(--orange); animation: loading 800ms ease-in-out infinite alternate; } +@keyframes loading { to { transform: scaleX(.3); opacity: .45; } } + +.toast-region { position: fixed; z-index: 200; right: 20px; bottom: 20px; display: grid; gap: 8px; width: min(360px, calc(100vw - 40px)); } +.toast { padding: 12px 14px; color: var(--paper); background: var(--storm); border-left: 4px solid var(--mint); box-shadow: 0 8px 28px rgba(16,24,32,.2); font-size: 12px; } +.toast[data-tone="error"] { border-color: var(--alert); } + +@media (max-width: 1100px) { + .masthead { grid-template-columns: 1fr auto; } + .coverage-summary { grid-column: 1 / -1; grid-row: 2; padding: 10px 0 0; border: 0; } + .query-deck { grid-template-columns: 1fr auto; } + .lens-switcher { grid-column: 1 / -1; } + .workspace { grid-template-columns: 190px minmax(480px, 1fr); } + .inspector { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); } + .inspector-empty { margin: 0; transform: none; } +} + +@media (max-width: 760px) { + .masthead { grid-template-columns: 1fr; gap: 12px; } + .mast-actions { justify-content: flex-start; } + .coverage-summary { display: grid; gap: 4px; } + .query-deck { grid-template-columns: 1fr; } + .lens-switcher, .search-field { grid-column: auto; } + .workspace { display: block; } + .context-rail { border-right: 0; border-bottom: 1px solid var(--line); } + .context-list { display: flex; overflow-x: auto; gap: 7px; margin: 14px 0; } + .context-list::before { display: none; } + .context-node { flex: 0 0 170px; border: 1px solid var(--line); padding: 9px; } + .fleet-board, .inspector { padding: 16px; } + .table-frame { min-height: 330px; } + .operation-grid { grid-template-columns: 1fr; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } +} diff --git a/internal/webui/assets/app.js b/internal/webui/assets/app.js new file mode 100644 index 0000000..0d3742b --- /dev/null +++ b/internal/webui/assets/app.js @@ -0,0 +1,413 @@ +"use strict"; + +const csrfToken = document.querySelector('meta[name="sith-csrf-token"]').content; +const state = { + meta: null, + snapshot: null, + lens: "Pod", + scope: "", + query: "", + correlate: false, + selected: null, + logAbort: null, + viewKey: "", +}; + +const dom = Object.fromEntries([ + "coverage-count", "coverage-detail", "lens-switcher", "fleet-search", "search-label", + "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", + "dialog-kicker", "dialog-body", "dialog-actions", "dialog-close", "loading-template", +].map((id) => [id, document.getElementById(id)])); + +async function api(path, options = {}) { + const headers = new Headers(options.headers || {}); + headers.set("X-Sith-CSRF", csrfToken); + if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json"); + const response = await fetch(path, {...options, headers}); + if (!response.ok) { + let message = `${response.status} ${response.statusText}`; + try { message = (await response.json()).error || message; } catch (_) { /* response was not JSON */ } + throw new Error(message); + } + return response; +} + +function node(tag, className = "", text = "") { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== "") element.textContent = text; + return element; +} + +function replaceChildren(parent, children = []) { parent.replaceChildren(...children); } + +function ageLabel(value) { + if (!value || value.startsWith("0001-")) return "never"; + const seconds = Math.max(0, Math.floor((Date.now() - new Date(value).getTime()) / 1000)); + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + return `${Math.floor(seconds / 3600)}h`; +} + +function coverageText(coverage = {}) { + const requested = coverage.requested || 0; + const reachable = coverage.reachable || 0; + const parts = [`covered ${reachable}/${requested} clusters`]; + if ((coverage.stale || []).length) parts.push(`${coverage.stale.length} stale (${coverage.stale.join(", ")})`); + if ((coverage.unreachable || []).length) parts.push(`${coverage.unreachable.length} unreachable (${coverage.unreachable.join(", ")})`); + return parts.join(" · "); +} + +function statusTone(status = "") { + const normalized = status.toLowerCase(); + if (["running", "ready", "healthy", "normal", "succeeded"].some((word) => normalized.includes(word))) return "good"; + if (["progress", "pending", "unknown"].some((word) => normalized.includes(word))) return "warn"; + if (["fail", "error", "crash", "degraded", "notready", "backoff"].some((word) => normalized.includes(word))) return "bad"; + return "muted"; +} + +function renderMeta() { + replaceChildren(dom["lens-switcher"]); + for (const lens of state.meta.lenses) { + const button = node("button", "", `${lens}s`); + button.type = "button"; + button.setAttribute("role", "listitem"); + button.setAttribute("aria-current", String(state.lens === lens)); + button.addEventListener("click", () => { state.lens = lens; state.selected = null; loadSnapshot(); renderMeta(); }); + dom["lens-switcher"].append(button); + } +} + +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); + 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); + renderRows(snapshot.records || []); + if (state.selected) { + const identity = recordIdentity(state.selected); + state.selected = snapshot.records.find((record) => recordIdentity(record) === identity) || null; + } + renderInspector(); +} + +function renderContexts(scopes, coverage) { + const entries = []; + const all = node("button", "context-node"); + all.type = "button"; + all.setAttribute("aria-current", String(!state.scope)); + all.dataset.state = (coverage.unreachable || []).length ? "stale" : "fresh"; + all.append(contextLabel("All contexts", compactCoverage(coverage))); + all.addEventListener("click", () => { state.scope = ""; loadSnapshot(); }); + entries.push(all); + for (const scope of scopes) { + 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.addEventListener("click", () => { state.scope = state.scope === scope.name ? "" : scope.name; loadSnapshot(); }); + entries.push(button); + } + replaceChildren(dom["context-list"], entries); +} + +function compactCoverage(coverage) { + return `${coverage.reachable || 0}/${coverage.requested || 0} covered · ${(coverage.stale || []).length} stale · ${(coverage.unreachable || []).length} down`; +} + +function contextLabel(name, detail) { + const wrapper = node("span"); + wrapper.append(node("strong", "", name), node("small", "", detail)); + return wrapper; +} + +function renderRows(records) { + const rows = records.map((record) => { + const row = node("tr"); + row.tabIndex = 0; + row.setAttribute("aria-selected", String(state.selected && recordIdentity(state.selected) === recordIdentity(record))); + row.append( + cell(record.cluster, "cell-context"), cell(record.namespace || "—"), cell(record.name), + cell(record.ready || "—"), statusCell(record.status || record.reason || "Unknown"), + cell(String(record.restarts || 0), "cell-restarts"), cell(ageLabel(record.observed_at), "cell-age"), + ); + const select = () => { state.selected = record; renderRows(records); renderInspector(); }; + row.addEventListener("click", select); + row.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); select(); } }); + return row; + }); + replaceChildren(dom["fleet-rows"], rows); + dom["empty-state"].hidden = records.length !== 0; +} + +function cell(text, className = "") { return node("td", className, text); } +function statusCell(text) { + const td = node("td"); + const status = node("span", "status-pill", text); + status.dataset.tone = statusTone(text); + td.append(status); + return td; +} +function recordIdentity(record) { return [record.cluster, record.kind, record.namespace, record.name].join("/"); } + +function renderInspector() { + const record = state.selected; + dom["inspector-empty"].hidden = Boolean(record); + dom["inspector-content"].hidden = !record; + if (!record) return; + dom["inspector-kind"].textContent = record.kind; + dom["inspector-name"].textContent = record.name; + dom["inspector-address"].textContent = `${record.cluster} / ${record.namespace || "cluster-scoped"}`; + const facts = [ + ["Status", record.status || record.reason || "Unknown"], ["Ready", record.ready || "—"], + ["Restarts", String(record.restarts || 0)], ["Node", record.node || "—"], + ["Observed", ageLabel(record.observed_at)], ["Image", (record.images || []).join(", ") || "—"], + ]; + replaceChildren(dom["inspector-facts"], facts.map(([label, value]) => { + const wrapper = node("div"); wrapper.append(node("dt", "", label), node("dd", "", value)); return wrapper; + })); + const operations = [ + ["Describe", () => showObject(true, false)], ["View YAML", () => showObject(false, false)], + ]; + if (record.kind === "Pod") operations.push(["Follow logs", showLogs], ["Run command", showExec], ["Forward port", showForward]); + if (record.kind === "Service") operations.push(["Forward port", showForward]); + operations.push(["Edit YAML", () => showEdit(false)]); + replaceChildren(dom["operation-grid"], operations.map(([label, action]) => { + const button = node("button", "", label); button.type = "button"; button.addEventListener("click", action); return button; + })); +} + +function selectedTarget() { + const record = state.selected; + return {context: record.cluster, namespace: record.namespace, kind: record.kind, name: record.name}; +} + +function targetQuery(target) { + return new URLSearchParams({context: target.context, namespace: target.namespace || "", kind: target.kind, name: target.name}); +} + +function openDialog(title, kicker = "Local operation") { + dom["dialog-title"].textContent = title; + dom["dialog-kicker"].textContent = kicker; + replaceChildren(dom["dialog-body"], [dom["loading-template"].content.cloneNode(true)]); + replaceChildren(dom["dialog-actions"]); + if (!dom["action-dialog"].open) dom["action-dialog"].showModal(); +} + +function dialogButton(label, action, primary = false) { + const button = node("button", "", label); button.type = "button"; button.dataset.primary = String(primary); button.addEventListener("click", action); return button; +} + +function showPre(content) { replaceChildren(dom["dialog-body"], [node("pre", "", content)]); } + +async function showObject(describe, reveal) { + const target = selectedTarget(); + openDialog(`${describe ? "Describe" : "YAML"} · ${target.name}`, `${target.context} / ${target.kind}`); + try { + const query = targetQuery(target); query.set("describe", String(describe)); query.set("reveal_secrets", String(reveal)); + const payload = await (await api(`/api/v1/object?${query}`)).json(); + if (!describe) { + showPre(payload.yaml); + const actions = []; + if (target.kind === "Secret" && !reveal) actions.push(dialogButton("Reveal Secret data", () => showObject(false, true))); + actions.push(dialogButton("Edit YAML", () => showEdit(false), true)); + replaceChildren(dom["dialog-actions"], actions); + return; + } + const container = node("div"); + container.append(node("pre", "", payload.yaml)); + const events = node("div", "event-list"); + if (!payload.events.length) events.append(node("div", "event-entry", "No related events.")); + for (const event of payload.events) { + const entry = node("div", "event-entry"); + entry.append(node("strong", "", `${event.type || "Event"} · ${event.reason || "Unknown"}`), node("span", "", event.message || "No message")); + events.append(entry); + } + container.append(events); replaceChildren(dom["dialog-body"], [container]); + } catch (error) { showDialogError(error); } +} + +async function showLogs() { + const target = selectedTarget(); + openDialog(`Logs · ${target.name}`, `${target.context} / follow`); + const output = node("pre", "", "Opening stream…\n"); + replaceChildren(dom["dialog-body"], [output]); + state.logAbort?.abort(); + state.logAbort = new AbortController(); + replaceChildren(dom["dialog-actions"], [dialogButton("Stop following", () => state.logAbort?.abort(), true)]); + try { + const query = targetQuery(target); query.set("follow", "true"); query.set("tail", "200"); + const response = await api(`/api/v1/logs?${query}`, {signal: state.logAbort.signal}); + output.textContent = ""; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + while (true) { + const {done, value} = await reader.read(); + if (done) break; + output.textContent += decoder.decode(value, {stream: true}); + output.scrollTop = output.scrollHeight; + } + } catch (error) { + if (error.name !== "AbortError") showDialogError(error); + } +} + +function showExec() { + const target = selectedTarget(); + openDialog(`Run command · ${target.name}`, `${target.context} / exact argv`); + const wrapper = node("div"); + const label = node("label", "", "Command arguments (JSON array)"); + const input = node("textarea"); input.value = '["uname", "-a"]'; input.style.minHeight = "110px"; label.append(input); wrapper.append(label); + replaceChildren(dom["dialog-body"], [wrapper]); + replaceChildren(dom["dialog-actions"], [dialogButton("Run command", async () => { + try { + const command = JSON.parse(input.value); + if (!Array.isArray(command) || command.some((part) => typeof part !== "string")) throw new Error("Command must be a JSON array of strings."); + replaceChildren(dom["dialog-actions"]); + const payload = await (await api("/api/v1/exec", {method: "POST", body: JSON.stringify({target, command})})).json(); + showPre(`${payload.stdout}${payload.stderr ? `\n[stderr]\n${payload.stderr}` : ""}${payload.truncated ? "\n[output truncated]" : ""}`); + } catch (error) { showDialogError(error); } + }, true)]); +} + +async function showEdit(revealSecret) { + const target = selectedTarget(); + if (target.kind === "Secret" && !revealSecret) { + openDialog(`Edit Secret · ${target.name}`, `${target.context} / explicit disclosure`); + replaceChildren(dom["dialog-body"], [node("div", "event-entry", "Editing this Secret reveals its data in the browser until the dialog closes.")]); + replaceChildren(dom["dialog-actions"], [dialogButton("Reveal and edit Secret", () => showEdit(true), true)]); + return; + } + openDialog(`Edit YAML · ${target.name}`, `${target.context} / server-validated`); + try { + const query = targetQuery(target); query.set("reveal_secrets", String(target.kind === "Secret" && revealSecret)); + const payload = await (await api(`/api/v1/object?${query}`)).json(); + const textarea = node("textarea"); textarea.value = payload.yaml; textarea.setAttribute("aria-label", "Resource YAML"); + replaceChildren(dom["dialog-body"], [textarea]); + let previewedManifest = ""; + let previewToken = ""; + const preview = dialogButton("Preview changes", async () => { + try { + const manifest = textarea.value; + const result = await (await api("/api/v1/edit/preview", {method: "POST", body: JSON.stringify({target, manifest})})).json(); + previewedManifest = manifest; + previewToken = result.preview_token; + const diff = node("pre", "", result.diff || "No changes."); + replaceChildren(dom["dialog-body"], [textarea, diff]); + replaceChildren(dom["dialog-actions"], [preview, dialogButton("Apply previewed YAML", async () => { + if (textarea.value !== previewedManifest) { toast("YAML changed after preview. Preview it again.", "error"); return; } + try { + await api("/api/v1/edit/apply", {method: "POST", body: JSON.stringify({target, manifest: previewedManifest, preview_token: previewToken})}); + toast(`${target.kind}/${target.name} updated in ${target.context}.`); + dom["action-dialog"].close(); loadSnapshot(); + } catch (error) { showDialogError(error); } + }, true)]); + } catch (error) { showDialogError(error); } + }); + replaceChildren(dom["dialog-actions"], [preview]); + } catch (error) { showDialogError(error); } +} + +function showForward() { + const target = selectedTarget(); + openDialog(`Forward port · ${target.name}`, `${target.context} / loopback only`); + const label = node("label", "", "Port mapping"); + const input = node("input"); input.value = ":8080"; input.placeholder = "LOCAL:REMOTE or :REMOTE"; label.append(input); + replaceChildren(dom["dialog-body"], [label]); + replaceChildren(dom["dialog-actions"], [dialogButton("Start loopback forward", async () => { + try { + const payload = await (await api("/api/v1/port-forwards", {method: "POST", body: JSON.stringify({target, ports: input.value.trim().split(/\s+/)})})).json(); + toast(`Forward ready: ${payload.ports.map((port) => `${port.local}→${port.remote}`).join(", ")}`); + dom["action-dialog"].close(); refreshForwardCount(); + } catch (error) { showDialogError(error); } + }, true)]); +} + +async function showForwards() { + openDialog("Active port-forwards", "Loopback session manager"); + try { + const forwards = await (await api("/api/v1/port-forwards")).json(); + const list = node("div", "forward-list"); + if (!forwards.length) list.append(node("div", "forward-entry", "No active port-forwards.")); + for (const forward of forwards) { + const entry = node("div", "forward-entry"); + entry.append(node("strong", "", `${forward.target.context} / ${forward.target.name}`), node("span", "", forward.ports.map((port) => `${port.local}→${port.remote}`).join(", "))); + const close = dialogButton("Close forward", async () => { await api(`/api/v1/port-forwards/${encodeURIComponent(forward.id)}`, {method: "DELETE"}); showForwards(); refreshForwardCount(); }); + entry.append(close); list.append(entry); + } + replaceChildren(dom["dialog-body"], [list]); + } catch (error) { showDialogError(error); } +} + +function showDialogError(error) { + const message = error instanceof Error ? error.message : String(error); + replaceChildren(dom["dialog-body"], [node("div", "event-entry", message)]); + replaceChildren(dom["dialog-actions"]); + toast(message, "error"); +} + +function toast(message, tone = "ok") { + const item = node("div", "toast", message); item.dataset.tone = tone; dom["toast-region"].append(item); + setTimeout(() => item.remove(), 4800); +} + +async function refreshForwardCount() { + try { const forwards = await (await api("/api/v1/port-forwards")).json(); dom["forward-count"].textContent = String(forwards.filter((entry) => !entry.done).length); } catch (_) { /* main view reports API failures */ } +} + +async function loadSnapshot() { + const query = new URLSearchParams({kind: state.lens}); + if (state.scope) query.set("scopes", state.scope); + if (state.query) { query.set("q", state.query); query.set("all", String(!state.correlate)); } + if (state.correlate) query.set("correlate", "true"); + try { + const next = await (await api(`/api/v1/snapshot?${query}`)).json(); + const viewKey = query.toString(); + if (state.snapshot && state.viewKey === viewKey && state.snapshot.version === next.version && + state.snapshot.state === next.state && JSON.stringify(state.snapshot.coverage) === JSON.stringify(next.coverage)) return; + state.snapshot = next; + state.viewKey = viewKey; + renderSnapshot(); + } catch (error) { toast(error.message, "error"); } +} + +let searchTimer = 0; +dom["fleet-search"].addEventListener("input", () => { + clearTimeout(searchTimer); searchTimer = setTimeout(() => { state.query = dom["fleet-search"].value.trim(); state.selected = null; loadSnapshot(); }, 180); +}); +dom["query-mode"].addEventListener("click", () => { + state.correlate = !state.correlate; + dom["query-mode"].setAttribute("aria-pressed", String(state.correlate)); + dom["query-mode"].textContent = state.correlate ? "Correlation mode" : "Search mode"; + dom["search-label"].textContent = state.correlate ? "Correlate the fleet" : "Search the fleet"; + dom["fleet-search"].placeholder = state.correlate ? "deploy/payments status!=Healthy" : "payments status:CrashLoopBackOff"; + state.selected = null; loadSnapshot(); +}); +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); +dom["dialog-close"].addEventListener("click", () => dom["action-dialog"].close()); +dom["action-dialog"].addEventListener("close", () => { state.logAbort?.abort(); state.logAbort = null; }); +document.addEventListener("keydown", (event) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); dom["fleet-search"].focus(); } +}); + +(async function start() { + try { + state.meta = await (await api("/api/v1/meta")).json(); + renderMeta(); + await Promise.all([loadSnapshot(), refreshForwardCount()]); + setInterval(loadSnapshot, 3000); + setInterval(refreshForwardCount, 5000); + } catch (error) { toast(`Fleet UI could not start: ${error.message}`, "error"); } +})(); diff --git a/internal/webui/assets/index.html b/internal/webui/assets/index.html new file mode 100644 index 0000000..dfe89c7 --- /dev/null +++ b/internal/webui/assets/index.html @@ -0,0 +1,120 @@ + + + + + + + + Sith — Fleet IDE + + + + + + +
+
+ +
+

Fleet plotting board

+

Sith / live

+
+
+
+ warming contexts + Cache rows appear as clusters answer. +
+
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+
+

Aggregated lens

+

Pods

+
+

0 cached rows

+
+
+ + + + + + + + + + + + + +
ContextNamespaceNameReadyStatusRestartsObserved
+ +
+
covered 0/0 clusters
+
+ + +
+ +
+ + +
+
+

Local operation

+

Resource

+
+ +
+
+ +
+ + + + diff --git a/internal/webui/forward.go b/internal/webui/forward.go new file mode 100644 index 0000000..ad0c329 --- /dev/null +++ b/internal/webui/forward.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 + +package webui + +import ( + "errors" + "fmt" + "sort" + "sync" + "time" + + "github.com/ArdurAI/sith/internal/localops" +) + +const maxActiveForwards = 16 + +type forwardView struct { + ID string `json:"id"` + Target localops.Target `json:"target"` + Ports []localops.ForwardedPort `json:"ports"` + Started time.Time `json:"started_at"` + Done bool `json:"done"` + Error string `json:"error,omitempty"` +} + +type managedForward struct { + view forwardView + session localops.ForwardSession + releaseOnce sync.Once +} + +type forwardManager struct { + mu sync.RWMutex + forwards map[string]*managedForward + slots chan struct{} +} + +func newForwardManager() *forwardManager { + return &forwardManager{ + forwards: make(map[string]*managedForward), + slots: make(chan struct{}, maxActiveForwards), + } +} + +func (manager *forwardManager) reserve() error { + select { + case manager.slots <- struct{}{}: + return nil + default: + return fmt.Errorf("at most %d active port-forwards are allowed", maxActiveForwards) + } +} + +func (manager *forwardManager) releaseReservation() { + <-manager.slots +} + +func (manager *forwardManager) add(target localops.Target, session localops.ForwardSession) (forwardView, error) { + id, err := randomToken(12) + if err != nil { + return forwardView{}, err + } + ports, err := session.Ports() + if err != nil { + return forwardView{}, fmt.Errorf("read forwarded ports: %w", err) + } + forward := &managedForward{ + view: forwardView{ID: id, Target: target, Ports: ports, Started: time.Now().UTC()}, + session: session, + } + manager.mu.Lock() + manager.forwards[id] = forward + manager.mu.Unlock() + go manager.monitor(forward) + view := forward.view + view.Ports = append([]localops.ForwardedPort(nil), view.Ports...) + return view, nil +} + +func (manager *forwardManager) monitor(forward *managedForward) { + err := <-forward.session.Done() + manager.mu.Lock() + forward.view.Done = true + if err != nil { + forward.view.Error = err.Error() + } + if current, exists := manager.forwards[forward.view.ID]; exists && current == forward { + delete(manager.forwards, forward.view.ID) + } + manager.mu.Unlock() + forward.releaseOnce.Do(manager.releaseReservation) +} + +func (manager *forwardManager) list() []forwardView { + manager.mu.RLock() + defer manager.mu.RUnlock() + result := make([]forwardView, 0, len(manager.forwards)) + for _, forward := range manager.forwards { + view := forward.view + view.Ports = append([]localops.ForwardedPort(nil), view.Ports...) + result = append(result, view) + } + sort.Slice(result, func(left, right int) bool { return result[left].Started.Before(result[right].Started) }) + return result +} + +func (manager *forwardManager) close(id string) error { + manager.mu.Lock() + forward, exists := manager.forwards[id] + if exists { + delete(manager.forwards, id) + } + manager.mu.Unlock() + if !exists { + return fmt.Errorf("port-forward %q was not found", id) + } + closeErr := forward.session.Close() + forward.releaseOnce.Do(manager.releaseReservation) + return closeErr +} + +func (manager *forwardManager) closeAll() error { + manager.mu.Lock() + forwards := manager.forwards + manager.forwards = make(map[string]*managedForward) + manager.mu.Unlock() + errorsList := make([]error, 0, len(forwards)) + for _, forward := range forwards { + errorsList = append(errorsList, forward.session.Close()) + forward.releaseOnce.Do(manager.releaseReservation) + } + return errors.Join(errorsList...) +} diff --git a/internal/webui/preview.go b/internal/webui/preview.go new file mode 100644 index 0000000..92f66cb --- /dev/null +++ b/internal/webui/preview.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 + +package webui + +import ( + "crypto/sha256" + "fmt" + "sync" + "time" + + "github.com/ArdurAI/sith/internal/localops" +) + +const previewLifetime = 5 * time.Minute + +type previewGrant struct { + digest [sha256.Size]byte + expires time.Time +} + +type previewManager struct { + mu sync.Mutex + grants map[string]previewGrant + now func() time.Time +} + +func newPreviewManager() *previewManager { + return &previewManager{grants: make(map[string]previewGrant), now: time.Now} +} + +func (manager *previewManager) issue(target localops.Target, manifest string) (string, error) { + token, err := randomToken(24) + if err != nil { + return "", err + } + now := manager.now().UTC() + manager.mu.Lock() + manager.removeExpiredLocked(now) + manager.grants[token] = previewGrant{digest: previewDigest(target, manifest), expires: now.Add(previewLifetime)} + manager.mu.Unlock() + return token, nil +} + +func (manager *previewManager) consume(token string, target localops.Target, manifest string) error { + now := manager.now().UTC() + manager.mu.Lock() + defer manager.mu.Unlock() + manager.removeExpiredLocked(now) + grant, exists := manager.grants[token] + delete(manager.grants, token) + if !exists || token == "" { + return fmt.Errorf("a fresh server dry-run preview is required before apply") + } + if grant.digest != previewDigest(target, manifest) { + return fmt.Errorf("the target or YAML changed after preview; preview again before apply") + } + return nil +} + +func (manager *previewManager) clear() { + manager.mu.Lock() + manager.grants = make(map[string]previewGrant) + manager.mu.Unlock() +} + +func (manager *previewManager) removeExpiredLocked(now time.Time) { + for token, grant := range manager.grants { + if !grant.expires.After(now) { + delete(manager.grants, token) + } + } +} + +func previewDigest(target localops.Target, manifest string) [sha256.Size]byte { + identity := target.Context + "\x00" + target.Namespace + "\x00" + target.Kind + "\x00" + target.Name + "\x00" + manifest + return sha256.Sum256([]byte(identity)) +} diff --git a/internal/webui/preview_test.go b/internal/webui/preview_test.go new file mode 100644 index 0000000..be6ad58 --- /dev/null +++ b/internal/webui/preview_test.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 + +package webui + +import ( + "strings" + "testing" + "time" + + "github.com/ArdurAI/sith/internal/localops" +) + +func TestPreviewGrantIsExactSingleUseAndExpires(t *testing.T) { + t.Parallel() + now := time.Date(2026, time.July, 10, 20, 0, 0, 0, time.UTC) + manager := newPreviewManager() + manager.now = func() time.Time { return now } + target := localops.Target{Context: "alpha", Namespace: "apps", Kind: "ConfigMap", Name: "settings"} + token, err := manager.issue(target, "mode: new\n") + if err != nil { + t.Fatalf("issue() error = %v", err) + } + if err := manager.consume(token, target, "mode: changed\n"); err == nil || !strings.Contains(err.Error(), "changed after preview") { + t.Fatalf("consume(changed) error = %v", err) + } + if err := manager.consume(token, target, "mode: new\n"); err == nil || !strings.Contains(err.Error(), "fresh server dry-run") { + t.Fatalf("consume(reused after mismatch) error = %v", err) + } + token, err = manager.issue(target, "mode: new\n") + if err != nil { + t.Fatal(err) + } + if err := manager.consume(token, target, "mode: new\n"); err != nil { + t.Fatalf("consume(exact) error = %v", err) + } + if err := manager.consume(token, target, "mode: new\n"); err == nil { + t.Fatal("consume(replay) error = nil") + } + token, err = manager.issue(target, "mode: new\n") + if err != nil { + t.Fatal(err) + } + now = now.Add(previewLifetime + time.Second) + if err := manager.consume(token, target, "mode: new\n"); err == nil { + t.Fatal("consume(expired) error = nil") + } +} diff --git a/internal/webui/server.go b/internal/webui/server.go new file mode 100644 index 0000000..1919108 --- /dev/null +++ b/internal/webui/server.go @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package webui serves Sith's embedded fleet frontend against a mode-neutral fleet API. +package webui + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "embed" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "io/fs" + "net" + "net/http" + "net/url" + "strings" + "sync" + "sync/atomic" + + "github.com/ArdurAI/sith/internal/fleetcache" + "github.com/ArdurAI/sith/internal/localops" +) + +const ( + csrfHeader = "X-Sith-CSRF" + localMode = "local" +) + +//go:embed assets/* +var embeddedAssets embed.FS + +// Syncer is the explicit background-refresh seam used by the web API. +type Syncer interface { + SyncOnce(ctx context.Context) error + SyncKinds(ctx context.Context, kinds ...string) error +} + +// Application owns the embedded frontend, cache API, and live local sessions. +type Application struct { + store *fleetcache.Store + syncer Syncer + local localops.Client + mode string + token string + assets fs.FS + forwards *forwardManager + previews *previewManager + ctx context.Context + cancel context.CancelFunc + refreshing atomic.Bool + + closeOnce sync.Once +} + +// New constructs a local-mode fleet web application without opening a listener. +func New(ctx context.Context, store *fleetcache.Store, syncer Syncer, local localops.Client) (*Application, error) { + if ctx == nil { + return nil, fmt.Errorf("construct web UI: context is nil") + } + if store == nil { + return nil, fmt.Errorf("construct web UI: store is nil") + } + if syncer == nil { + return nil, fmt.Errorf("construct web UI: syncer is nil") + } + if local == nil { + return nil, fmt.Errorf("construct web UI: local operations client is nil") + } + assets, err := fs.Sub(embeddedAssets, "assets") + if err != nil { + return nil, fmt.Errorf("load embedded frontend: %w", err) + } + token, err := randomToken(32) + if err != nil { + return nil, err + } + applicationCtx, cancel := context.WithCancel(ctx) + return &Application{ + store: store, syncer: syncer, local: local, mode: localMode, token: token, + assets: assets, forwards: newForwardManager(), previews: newPreviewManager(), + ctx: applicationCtx, cancel: cancel, + }, nil +} + +// Handler returns the hardened frontend/API handler for one exact listener URL. +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") + } + mux := http.NewServeMux() + mux.HandleFunc("GET /", application.serveIndex) + mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServerFS(application.assets))) + application.registerAPI(mux) + return application.securityMiddleware(parsed)(mux), nil +} + +// Close stops every long-lived local session owned by this application. +func (application *Application) Close() error { + var closeErr error + application.closeOnce.Do(func() { + application.cancel() + application.previews.clear() + closeErr = application.forwards.closeAll() + }) + return closeErr +} + +// ValidateLoopbackAddress refuses any local-mode listener that is not loopback. +func ValidateLoopbackAddress(address string) error { + trimmed := strings.TrimSpace(address) + if trimmed == "localhost" { + return nil + } + ip := net.ParseIP(trimmed) + if ip == nil || !ip.IsLoopback() { + return fmt.Errorf("local web UI address %q is not loopback", address) + } + return nil +} + +func (application *Application) serveIndex(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/" { + http.NotFound(response, request) + return + } + payload, err := fs.ReadFile(application.assets, "index.html") + if err != nil { + writeAPIError(response, http.StatusInternalServerError, "embedded frontend is unavailable") + return + } + page := strings.ReplaceAll(string(payload), "__SITH_CSRF_TOKEN__", application.token) + page = strings.ReplaceAll(page, "__SITH_MODE__", application.mode) + response.Header().Set("Content-Type", "text/html; charset=utf-8") + response.Header().Set("Cache-Control", "no-store") + _, _ = response.Write([]byte(page)) +} + +func (application *Application) securityMiddleware(origin *url.URL) func(http.Handler) http.Handler { + expectedHost := origin.Host + expectedOrigin := origin.Scheme + "://" + origin.Host + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + setSecurityHeaders(response.Header()) + if request.Host != expectedHost { + writeAPIError(response, http.StatusForbidden, "request host does not match the local listener") + return + } + if strings.HasPrefix(request.URL.Path, "/api/") { + if supplied := request.Header.Get(csrfHeader); subtle.ConstantTimeCompare([]byte(supplied), []byte(application.token)) != 1 { + writeAPIError(response, http.StatusForbidden, "missing or invalid local session capability") + return + } + if suppliedOrigin := request.Header.Get("Origin"); suppliedOrigin != "" && suppliedOrigin != expectedOrigin { + writeAPIError(response, http.StatusForbidden, "request origin does not match the local listener") + return + } + response.Header().Set("Cache-Control", "no-store") + } + next.ServeHTTP(response, request) + }) + } +} + +func setSecurityHeaders(header http.Header) { + header.Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'") + header.Set("Permissions-Policy", "camera=(), display-capture=(), geolocation=(), microphone=(), payment=(), usb=()") + header.Set("Referrer-Policy", "no-referrer") + header.Set("X-Content-Type-Options", "nosniff") + header.Set("X-Frame-Options", "DENY") + header.Set("Cross-Origin-Resource-Policy", "same-origin") +} + +func randomToken(bytes int) (string, error) { + payload := make([]byte, bytes) + if _, err := rand.Read(payload); err != nil { + return "", fmt.Errorf("generate local web session capability: %w", err) + } + return base64.RawURLEncoding.EncodeToString(payload), nil +} + +func decodeJSON(response http.ResponseWriter, request *http.Request, limit int64, destination any) error { + request.Body = http.MaxBytesReader(response, request.Body, limit) + decoder := json.NewDecoder(request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return fmt.Errorf("decode request JSON: %w", err) + } + if decoder.Decode(&struct{}{}) != io.EOF { + return fmt.Errorf("decode request JSON: multiple values are not allowed") + } + return nil +} + +func writeJSON(response http.ResponseWriter, status int, value any) { + response.Header().Set("Content-Type", "application/json; charset=utf-8") + response.WriteHeader(status) + _ = json.NewEncoder(response).Encode(value) +} + +func writeAPIError(response http.ResponseWriter, status int, message string) { + writeJSON(response, status, map[string]string{"error": message}) +} diff --git a/internal/webui/server_test.go b/internal/webui/server_test.go new file mode 100644 index 0000000..2cdc414 --- /dev/null +++ b/internal/webui/server_test.go @@ -0,0 +1,446 @@ +// SPDX-License-Identifier: Apache-2.0 + +package webui + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "sync/atomic" + "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" +) + +const testOrigin = "http://127.0.0.1:7407" + +func TestValidateLoopbackAddress(t *testing.T) { + t.Parallel() + for _, address := range []string{"127.0.0.1", "::1", "localhost"} { + if err := ValidateLoopbackAddress(address); err != nil { + t.Errorf("ValidateLoopbackAddress(%q) error = %v", address, err) + } + } + for _, address := range []string{"0.0.0.0", "::", "192.0.2.10", "example.com", ""} { + if err := ValidateLoopbackAddress(address); err == nil { + t.Errorf("ValidateLoopbackAddress(%q) error = nil", address) + } + } +} + +func TestHandlerEnforcesHostOriginCapabilityAndSecurityHeaders(t *testing.T) { + t.Parallel() + application := testApplication(t) + handler := testHandler(t, application) + + index := serve(handler, http.MethodGet, "/", "", nil) + if index.Code != http.StatusOK || !strings.Contains(index.Body.String(), application.token) || + strings.Contains(index.Body.String(), "__SITH_CSRF_TOKEN__") { + t.Fatalf("index status/body = %d/%q", index.Code, index.Body.String()) + } + for _, header := range []string{"Content-Security-Policy", "Permissions-Policy", "X-Frame-Options", "X-Content-Type-Options"} { + if index.Header().Get(header) == "" { + t.Errorf("index missing %s", header) + } + } + asset := serve(handler, http.MethodGet, "/assets/app.js", "", nil) + if asset.Code != http.StatusOK || strings.Contains(asset.Body.String(), "https://") { + t.Fatalf("asset status/external reference = %d/%t", asset.Code, strings.Contains(asset.Body.String(), "https://")) + } + + missing := serve(handler, http.MethodGet, "/api/v1/meta", "", nil) + if missing.Code != http.StatusForbidden { + t.Fatalf("missing capability status = %d", missing.Code) + } + request := httptest.NewRequest(http.MethodGet, testOrigin+"/api/v1/meta", nil) + request.Host = "attacker.invalid" + request.Header.Set(csrfHeader, application.token) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusForbidden { + t.Fatalf("wrong host status = %d", recorder.Code) + } + wrongOriginHeaders := http.Header{"Origin": []string{"https://attacker.invalid"}} + wrongOrigin := serve(handler, http.MethodGet, "/api/v1/meta", application.token, wrongOriginHeaders) + if wrongOrigin.Code != http.StatusForbidden { + t.Fatalf("wrong origin status = %d", wrongOrigin.Code) + } + meta := serve(handler, http.MethodGet, "/api/v1/meta", application.token, nil) + if meta.Code != http.StatusOK { + t.Fatalf("meta status/body = %d/%s", meta.Code, meta.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(meta.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode meta: %v", err) + } + if payload["mode"] != "local" || payload["account_required"] != false || payload["telemetry"] != false { + t.Fatalf("meta = %#v", payload) + } +} + +func TestSnapshotReadsCacheOnlyAndRefreshIsExplicit(t *testing.T) { + t.Parallel() + syncer := &webSyncer{} + store := populatedWebStore(t) + application, err := New(t.Context(), store, syncer, &webLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = application.Close() }) + handler := testHandler(t, application) + snapshot := serve(handler, http.MethodGet, "/api/v1/snapshot?kind=Pod&q=status:Running", application.token, nil) + if snapshot.Code != http.StatusOK || syncer.calls.Load() != 0 { + t.Fatalf("snapshot status/sync calls = %d/%d", snapshot.Code, syncer.calls.Load()) + } + var decoded fleetcache.Snapshot + if err := json.Unmarshal(snapshot.Body.Bytes(), &decoded); err != nil { + t.Fatal(err) + } + if len(decoded.Records) != 1 || decoded.Records[0].Cluster != "alpha" { + t.Fatalf("snapshot = %#v", decoded) + } + refresh := serveBody(handler, http.MethodPost, "/api/v1/sync", application.token, `{}`) + if refresh.Code != http.StatusAccepted { + t.Fatalf("refresh status/body = %d/%s", refresh.Code, refresh.Body.String()) + } + deadline := time.Now().Add(time.Second) + for syncer.calls.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if syncer.calls.Load() != 1 { + t.Fatalf("sync calls = %d", syncer.calls.Load()) + } +} + +func TestRefreshRequestsAreSingleFlight(t *testing.T) { + t.Parallel() + syncer := &blockingWebSyncer{started: make(chan struct{}), release: make(chan struct{})} + application, err := New(t.Context(), populatedWebStore(t), syncer, &webLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = application.Close() }) + handler := testHandler(t, application) + first := serveBody(handler, http.MethodPost, "/api/v1/sync", application.token, `{}`) + if first.Code != http.StatusAccepted { + t.Fatalf("first refresh status/body = %d/%s", first.Code, first.Body.String()) + } + select { + case <-syncer.started: + case <-time.After(time.Second): + t.Fatal("first refresh did not start") + } + second := serveBody(handler, http.MethodPost, "/api/v1/sync", application.token, `{}`) + if second.Code != http.StatusAccepted || !strings.Contains(second.Body.String(), "already running") || + syncer.calls.Load() != 1 { + t.Fatalf("coalesced refresh status/body/calls = %d/%s/%d", second.Code, second.Body.String(), syncer.calls.Load()) + } + close(syncer.release) +} + +func TestLocalOperationAPIUsesExactTargetAndPreviewBeforeApply(t *testing.T) { + t.Parallel() + client := &webLocalClient{ + view: localops.ObjectView{YAML: []byte("kind: Pod\nmetadata:\n name: api\n")}, + preview: localops.ApplyPreview{CurrentYAML: []byte("mode: old\n"), DryRunYAML: []byte("mode: new\n")}, + } + application := testApplicationWithLocal(t, client) + handler := testHandler(t, application) + query := "context=alpha&namespace=apps&kind=Pod&name=api" + object := serve(handler, http.MethodGet, "/api/v1/object?"+query, application.token, nil) + if object.Code != http.StatusOK || !strings.Contains(object.Body.String(), "kind: Pod") || client.target.Context != "alpha" { + t.Fatalf("object status/body/target = %d/%s/%#v", object.Code, object.Body.String(), client.target) + } + logs := serve(handler, http.MethodGet, "/api/v1/logs?"+query, application.token, nil) + if logs.Code != http.StatusOK || logs.Body.String() != "alpha logs\n" { + t.Fatalf("logs status/body = %d/%q", logs.Code, logs.Body.String()) + } + execBody := `{"target":{"context":"alpha","namespace":"apps","kind":"Pod","name":"api"},"command":["printf","%s","$(literal)"]}` + executed := serveBody(handler, http.MethodPost, "/api/v1/exec", application.token, execBody) + if executed.Code != http.StatusOK || !slices.Equal(client.command, []string{"printf", "%s", "$(literal)"}) { + t.Fatalf("exec status/body/argv = %d/%s/%q", executed.Code, executed.Body.String(), client.command) + } + editPayload := editRequest{ + Target: localops.Target{Context: "alpha", Namespace: "apps", Kind: "ConfigMap", Name: "settings"}, + Manifest: "mode: new\n", + } + editBody, _ := json.Marshal(editPayload) + preview := serveBody(handler, http.MethodPost, "/api/v1/edit/preview", application.token, string(editBody)) + if preview.Code != http.StatusOK || !strings.Contains(preview.Body.String(), "server dry-run") { + t.Fatalf("preview status/body = %d/%s", preview.Code, preview.Body.String()) + } + withoutPreview := serveBody(handler, http.MethodPost, "/api/v1/edit/apply", application.token, string(editBody)) + if withoutPreview.Code != http.StatusConflict { + t.Fatalf("apply without preview status/body = %d/%s", withoutPreview.Code, withoutPreview.Body.String()) + } + var grant editPreviewResponse + if err := json.Unmarshal(preview.Body.Bytes(), &grant); err != nil || grant.PreviewToken == "" { + t.Fatalf("decode preview grant: %#v/%v", grant, err) + } + editPayload.PreviewToken = grant.PreviewToken + applyBody, _ := json.Marshal(editPayload) + applied := serveBody(handler, http.MethodPost, "/api/v1/edit/apply", application.token, string(applyBody)) + if applied.Code != http.StatusOK || !slices.Equal(client.order, []string{"view", "logs", "exec", "preview", "apply"}) { + t.Fatalf("apply status/order = %d/%v", applied.Code, client.order) + } +} + +func TestPortForwardAPIOwnsAndClosesSession(t *testing.T) { + t.Parallel() + client := &webLocalClient{} + application := testApplicationWithLocal(t, client) + handler := testHandler(t, application) + body := `{"target":{"context":"alpha","namespace":"apps","kind":"Service","name":"api"},"ports":[":http"]}` + started := serveBody(handler, http.MethodPost, "/api/v1/port-forwards", application.token, body) + if started.Code != http.StatusCreated { + t.Fatalf("start status/body = %d/%s", started.Code, started.Body.String()) + } + var forward forwardView + if err := json.Unmarshal(started.Body.Bytes(), &forward); err != nil { + t.Fatal(err) + } + listed := serve(handler, http.MethodGet, "/api/v1/port-forwards", application.token, nil) + if listed.Code != http.StatusOK || !strings.Contains(listed.Body.String(), forward.ID) { + t.Fatalf("list status/body = %d/%s", listed.Code, listed.Body.String()) + } + closed := serve(handler, http.MethodDelete, "/api/v1/port-forwards/"+forward.ID, application.token, nil) + if closed.Code != http.StatusNoContent || client.session == nil || !client.session.closed.Load() { + t.Fatalf("close status/session = %d/%#v", closed.Code, client.session) + } +} + +func TestPortForwardAPILimitsActiveSessions(t *testing.T) { + t.Parallel() + client := &webLocalClient{} + application := testApplicationWithLocal(t, client) + handler := testHandler(t, application) + var first forwardView + for index := range maxActiveForwards { + body := fmt.Sprintf( + `{"target":{"context":"alpha","namespace":"apps","kind":"Service","name":"api-%d"},"ports":[":http"]}`, + index, + ) + started := serveBody(handler, http.MethodPost, "/api/v1/port-forwards", application.token, body) + if started.Code != http.StatusCreated { + t.Fatalf("start forward %d status/body = %d/%s", index, started.Code, started.Body.String()) + } + if index == 0 { + if err := json.Unmarshal(started.Body.Bytes(), &first); err != nil { + t.Fatal(err) + } + } + } + overLimit := serveBody( + handler, + http.MethodPost, + "/api/v1/port-forwards", + application.token, + `{"target":{"context":"alpha","namespace":"apps","kind":"Service","name":"overflow"},"ports":[":http"]}`, + ) + if overLimit.Code != http.StatusTooManyRequests || !strings.Contains(overLimit.Body.String(), "at most 16") || + len(client.order) != maxActiveForwards { + t.Fatalf("over-limit status/body/operations = %d/%s/%d", overLimit.Code, overLimit.Body.String(), len(client.order)) + } + closed := serve(handler, http.MethodDelete, "/api/v1/port-forwards/"+first.ID, application.token, nil) + if closed.Code != http.StatusNoContent { + t.Fatalf("close first forward status = %d", closed.Code) + } + replacement := serveBody( + handler, + http.MethodPost, + "/api/v1/port-forwards", + application.token, + `{"target":{"context":"alpha","namespace":"apps","kind":"Service","name":"replacement"},"ports":[":http"]}`, + ) + if replacement.Code != http.StatusCreated { + t.Fatalf("replacement forward status/body = %d/%s", replacement.Code, replacement.Body.String()) + } +} + +func testApplication(t *testing.T) *Application { + t.Helper() + return testApplicationWithLocal(t, &webLocalClient{}) +} + +func testApplicationWithLocal(t *testing.T, local localops.Client) *Application { + t.Helper() + application, err := New(t.Context(), populatedWebStore(t), &webSyncer{}, local) + if err != nil { + t.Fatalf("New() error = %v", err) + } + t.Cleanup(func() { _ = application.Close() }) + return application +} + +func testHandler(t *testing.T, application *Application) http.Handler { + t.Helper() + handler, err := application.Handler(testOrigin) + if err != nil { + t.Fatalf("Handler() error = %v", err) + } + return handler +} + +func serve(handler http.Handler, method, path, token string, headers http.Header) *httptest.ResponseRecorder { + return serveRequest(handler, method, path, token, headers, nil) +} + +func serveBody(handler http.Handler, method, path, token, body string) *httptest.ResponseRecorder { + return serveRequest(handler, method, path, token, nil, strings.NewReader(body)) +} + +func serveRequest( + handler http.Handler, + method, path, token string, + headers http.Header, + body io.Reader, +) *httptest.ResponseRecorder { + request := httptest.NewRequest(method, testOrigin+path, body) + request.Host = "127.0.0.1:7407" + if token != "" { + request.Header.Set(csrfHeader, token) + } + for name, values := range headers { + request.Header[name] = append([]string(nil), values...) + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + return recorder +} + +func populatedWebStore(t *testing.T) *fleetcache.Store { + t.Helper() + now := time.Now().UTC() + store := fleetcache.New() + store.SetDiscovery(connector.Discovery{Scopes: []connector.Scope{{Name: "alpha", Reachable: true, ObservedAt: now}}}) + observed := json.RawMessage(`{"apiVersion":"v1","kind":"Pod","metadata":{"name":"api","namespace":"apps"},"status":{"phase":"Running"}}`) + err := store.Replace("Pod", fleet.QueryResult{ + Facts: []fleet.Fact{{Evidence: fleet.Evidence{ + Ref: fleet.ResourceRef{SourceKind: "test", Scope: "alpha", Kind: "Pod", Namespace: "apps", Name: "api"}, + Kind: fleet.FactInventory, Observed: observed, ObservedAt: now, + }, Workspace: fleet.LocalWorkspace}}, + Coverage: fleet.Coverage{Requested: 1, Reachable: 1}, + }) + if err != nil { + t.Fatalf("populate store: %v", err) + } + return store +} + +type webSyncer struct{ calls atomic.Int32 } + +func (syncer *webSyncer) SyncOnce(context.Context) error { + syncer.calls.Add(1) + return nil +} +func (syncer *webSyncer) SyncKinds(context.Context, ...string) error { + syncer.calls.Add(1) + return nil +} + +type blockingWebSyncer struct { + calls atomic.Int32 + started chan struct{} + release chan struct{} + once sync.Once +} + +func (syncer *blockingWebSyncer) SyncOnce(ctx context.Context) error { + return syncer.run(ctx) +} + +func (syncer *blockingWebSyncer) SyncKinds(ctx context.Context, _ ...string) error { + return syncer.run(ctx) +} + +func (syncer *blockingWebSyncer) run(ctx context.Context) error { + syncer.calls.Add(1) + syncer.once.Do(func() { close(syncer.started) }) + select { + case <-syncer.release: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +type webLocalClient struct { + mu sync.Mutex + target localops.Target + view localops.ObjectView + preview localops.ApplyPreview + command []string + order []string + session *webForwardSession +} + +func (client *webLocalClient) record(target localops.Target, operation string) { + client.mu.Lock() + defer client.mu.Unlock() + client.target = target + client.order = append(client.order, operation) +} + +func (client *webLocalClient) View(_ context.Context, target localops.Target, _ bool) (localops.ObjectView, error) { + client.record(target, "view") + return client.view, nil +} +func (client *webLocalClient) Describe(_ context.Context, target localops.Target) (localops.Description, error) { + client.record(target, "describe") + return localops.Description{Object: client.view}, nil +} +func (client *webLocalClient) Logs(_ context.Context, target localops.Target, _ localops.LogOptions) (io.ReadCloser, error) { + client.record(target, "logs") + return io.NopCloser(strings.NewReader("alpha logs\n")), nil +} +func (client *webLocalClient) Exec(_ context.Context, target localops.Target, options localops.ExecOptions, streams localops.Streams) error { + client.record(target, "exec") + client.command = append([]string(nil), options.Command...) + _, _ = io.WriteString(streams.Stdout, "command output\n") + return nil +} +func (client *webLocalClient) PortForward(_ context.Context, request localops.ForwardRequest) (localops.ForwardSession, error) { + client.record(request.Target, "forward") + ready := make(chan struct{}) + close(ready) + client.session = &webForwardSession{ready: ready, done: make(chan error, 1)} + return client.session, nil +} +func (client *webLocalClient) PreviewApply(_ context.Context, target localops.Target, _ []byte) (localops.ApplyPreview, error) { + client.record(target, "preview") + return client.preview, nil +} +func (client *webLocalClient) Apply(_ context.Context, target localops.Target, _ []byte) (fleet.Evidence, error) { + client.record(target, "apply") + return fleet.Evidence{Ref: fleet.ResourceRef{Scope: target.Context, Kind: target.Kind, Name: target.Name}}, nil +} + +type webForwardSession struct { + ready <-chan struct{} + done chan error + closed atomic.Bool + once sync.Once +} + +func (session *webForwardSession) Ready() <-chan struct{} { return session.ready } +func (session *webForwardSession) Done() <-chan error { return session.done } +func (*webForwardSession) Ports() ([]localops.ForwardedPort, error) { + return []localops.ForwardedPort{{Local: 18080, Remote: 8080}}, nil +} +func (session *webForwardSession) Close() error { + session.once.Do(func() { + session.closed.Store(true) + session.done <- nil + }) + return nil +} diff --git a/sessions/2026-07-10-slice-4-local-web-ide.md b/sessions/2026-07-10-slice-4-local-web-ide.md new file mode 100644 index 0000000..f1d8ca9 --- /dev/null +++ b/sessions/2026-07-10-slice-4-local-web-ide.md @@ -0,0 +1,62 @@ +# Session — 2026-07-10 — slice-4-local-web-ide + +**Builder:** Gnani Rahul · **Model/effort:** engineering, max · **Branch:** gnanirahulnutakki/feat/local-web-ide +**Slice(s):** Slice 4 / #34 · **Status:** ready-for-PR + +--- + +[G] Goal: Ship `sith ui` as the embedded, loopback-only visual fleet IDE over the same cache, +search/correlation semantics, and local per-resource operations as the CLI/TUI. +[S] Scope: reusable embedded frontend, local HTTP server and cache API, browser fleet/search/detail +flows, per-resource operations, listener/origin/CSRF hardening, browser tests, and real two-cluster +parity. Hub auth/governance, desktop wrappers, persisted state, accounts, telemetry, and Slice-5 +keychain work are out. +[A] Action: Started from released `origin/dev` merge c419152 after Slice 3 PR/release/post-merge +CI and CodeQL completed successfully. GitHub Dependabot, CodeQL, and secret-scanning queues are +empty. Selected a build-free embedded frontend so the Go binary remains the only install artifact. +[A] Action: Chose a fleet plotting-board visual system: dense three-region workspace, a connected +context signal rail for coverage, local-only typography, and one restrained refresh scan motion. +Decorative imagery and generic card-dashboard patterns are intentionally excluded. +[A] Action: Defined the local browser boundary as loopback listener validation plus exact Host/ +Origin enforcement and a per-process CSRF capability header. Static assets use a restrictive CSP +and make no third-party requests; the same embedded frontend consumes a mode-neutral fleet API so +the future hub console can serve it unchanged. +[A] Action: Bound every YAML apply to a five-minute, single-use server preview capability hashed +over the exact target and manifest. The adapter performs a strict server dry-run for preview and +repeats validation on apply, so stale or altered browser payloads fail closed. +[C] Checkpoint #1: 25aba5f — embedded loopback server, mode-neutral cache/operation API, +responsive fleet frontend, CLI lifecycle, and unit/race coverage; next: real-process and +multi-cluster proof. +[A] Action: Verified desktop and 390 px responsive flows with Playwright: cache search, fleet +correlation, exact-row inspector, YAML, streaming logs, edit preview/apply, and owned port-forward +lifecycle. Browser console evidence was zero errors and zero warnings; decorative images remained +unnecessary for this operational surface. +[A] Action: Extended the digest-pinned two-kind-cluster gate to start the real `sith ui` process +on an ephemeral loopback port and prove partial coverage, same-model search/correlation, secret +redaction, exact-context logs/exec, preview-required apply, live forwarded HTTP, explicit refresh, +capability rejection, and external-bind refusal. +[C] Checkpoint #2: 779a0ac — terminating-command smoke updated to assert the serving lifecycle, +plus digest-pinned two-cluster API and operation parity; next: review and publication closure. +[R] Review: CodeRabbit CLI was not installed. The native Codex Security setup was attempted, but +the desktop requested missing `ui://codex-security/0.1.55/workspace.html` while the server +advertised 0.1.63, so no scan was represented as complete. The documented local fallback reviewed +every changed handler, browser sink, capability boundary, session owner, and test surface. +[R] Review: The red-team pass found and fixed four issues: coalesced explicit refreshes prevent +overlapping hydrations; a reservation-first 16-session cap bounds port-forward resources; Secret +edit requires an explicit disclosure action; and the web application now derives its context from +the command rather than detaching background work. The stale UI-stub subprocess test was replaced +with startup, embedded-index, and graceful-interrupt evidence. +[T] Test: Module tidy/verification, gofmt, vet, golangci-lint, build, race+coverage, warm-cache p95, +tagged binary smoke, JavaScript syntax, and govulncheck all pass; lint reports zero issues, +govulncheck reports no reachable vulnerabilities, and `internal/webui` coverage is 73.6%. +The final `make e2e-kind` race gate passed in 73.798 seconds; Docker cleanup reclaimed 3.756 GB. +GitHub reports zero open Dependabot, CodeQL, or secret-scanning alerts, and `origin/dev` remains at +the branch base c419152. +[A] Action: Updated README status, commands, loopback/capability/CSP/preview boundaries, and the +expanded real-cluster gate. No remote image or asset dependency was introduced. +[C] Checkpoint #3: this commit — documentation, red-team record, security evidence, and PR closure; +next: publish into `dev` and require green CI. + +--- + +**Session close:** ready for PR · **Open questions touched:** Q12 follows the locked TUI-first, web-fast-follow sequence diff --git a/tests/e2e/kind_fanout_test.go b/tests/e2e/kind_fanout_test.go index b5b09a8..5948b43 100644 --- a/tests/e2e/kind_fanout_test.go +++ b/tests/e2e/kind_fanout_test.go @@ -123,6 +123,7 @@ func TestKindFleetFanout(t *testing.T) { binary := filepath.Join(t.TempDir(), "sith") runCommand(ctx, t, root, "go", "build", "-trimpath", "-o", binary, "./cmd/sith") exerciseLocalOperations(ctx, t, binary, kubeconfigPath, clusterNames) + exerciseWebUI(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()) output, err := command.CombinedOutput() diff --git a/tests/e2e/kind_web_ui_test.go b/tests/e2e/kind_web_ui_test.go new file mode 100644 index 0000000..0ced2a7 --- /dev/null +++ b/tests/e2e/kind_web_ui_test.go @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: Apache-2.0 +//go:build e2e && kind + +package e2e_test + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/ArdurAI/sith/internal/fleetcache" + "github.com/ArdurAI/sith/internal/localops" +) + +var ( + webUIAddressPattern = regexp.MustCompile(`^sith ui listening on (http://127\.0\.0\.1:[0-9]+)$`) + webUITokenPattern = regexp.MustCompile(`name="sith-csrf-token" content="([^"]+)"`) +) + +type webUIProcess struct { + command *exec.Cmd + stderr *bytes.Buffer + once sync.Once +} + +type webUIObject struct { + Target localops.Target `json:"target"` + YAML string `json:"yaml"` +} + +type webUIExecResult struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +type webUIPreview struct { + Diff string `json:"diff"` + PreviewToken string `json:"preview_token"` +} + +type webUIForward struct { + ID string `json:"id"` + Target localops.Target `json:"target"` + Ports []localops.ForwardedPort `json:"ports"` +} + +func exerciseWebUI( + ctx context.Context, + t *testing.T, + binary, kubeconfigPath string, + clusters []string, +) { + t.Helper() + _, refusedStderr, err := runSith(ctx, binary, kubeconfigPath, "ui", "--no-open", "--address", "0.0.0.0") + if err == nil || !strings.Contains(refusedStderr, "is not loopback") { + t.Fatalf("external web UI bind error/stderr = %v/%q, want loopback refusal", err, refusedStderr) + } + + process, origin := startWebUI(ctx, t, binary, kubeconfigPath) + t.Cleanup(func() { process.stop(t) }) + client := &http.Client{Timeout: 20 * time.Second} + + index := webUIRequest(ctx, t, client, http.MethodGet, origin, "", "", nil) + if index.StatusCode != http.StatusOK || !strings.Contains(string(index.Body), "Sith — Fleet IDE") { + t.Fatalf("web UI index status/body = %d/%q", index.StatusCode, index.Body) + } + match := webUITokenPattern.FindSubmatch(index.Body) + if len(match) != 2 { + t.Fatalf("web UI index did not contain a local session capability") + } + token := string(match[1]) + + missing := webUIRequest(ctx, t, client, http.MethodGet, origin, "/api/v1/meta", "", nil) + if missing.StatusCode != http.StatusForbidden { + t.Fatalf("web UI API without capability status = %d, want 403", missing.StatusCode) + } + meta := webUIRequest(ctx, t, client, http.MethodGet, origin, "/api/v1/meta", token, nil) + var metadata map[string]any + decodeWebUIJSON(t, meta, http.StatusOK, &metadata) + if metadata["mode"] != "local" || metadata["account_required"] != false || metadata["telemetry"] != false { + t.Fatalf("web UI metadata = %#v", metadata) + } + + contexts := []string{"kind-" + clusters[0], "kind-" + clusters[1]} + snapshot := waitForWebUISnapshot(ctx, t, client, origin, token, contexts) + if snapshot.Coverage.Requested != 3 || snapshot.Coverage.Reachable != 2 || + !slices.Equal(snapshot.Coverage.Unreachable, []string{"kind-sith-e2e-unreachable"}) { + t.Fatalf("web UI snapshot coverage = %#v, want two of three reachable", snapshot.Coverage) + } + + searchPath := "/api/v1/snapshot?kind=Pod&q=" + url.QueryEscape("image:*log4j*") + search := webUIRequest(ctx, t, client, http.MethodGet, origin, searchPath, token, nil) + var searchSnapshot fleetcache.Snapshot + decodeWebUIJSON(t, search, http.StatusOK, &searchSnapshot) + if len(searchSnapshot.Records) != 1 || searchSnapshot.Records[0].Name != "sith-vuln-sample" || + searchSnapshot.Records[0].Cluster != contexts[0] { + t.Fatalf("web UI search records = %#v", searchSnapshot.Records) + } + + correlationPath := "/api/v1/snapshot?correlate=true&q=" + + url.QueryEscape("deploy/sith-payments status!=Healthy") + correlation := webUIRequest(ctx, t, client, http.MethodGet, origin, correlationPath, token, nil) + var correlationSnapshot fleetcache.Snapshot + decodeWebUIJSON(t, correlation, http.StatusOK, &correlationSnapshot) + if len(correlationSnapshot.Records) != 1 || correlationSnapshot.Records[0].Cluster != contexts[1] || + correlationSnapshot.Records[0].Status == "Healthy" { + t.Fatalf("web UI correlation records = %#v", correlationSnapshot.Records) + } + + objectPath := webUITargetPath("/api/v1/object", localops.Target{ + Context: contexts[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 != contexts[0] || !strings.Contains(viewed.YAML, contexts[0]) || + strings.Contains(viewed.YAML, contexts[1]) { + t.Fatalf("web UI exact object target/yaml = %#v/%q", viewed.Target, viewed.YAML) + } + + secretPath := webUITargetPath("/api/v1/object", localops.Target{ + Context: contexts[0], Namespace: "default", Kind: "Secret", Name: "sith-local-secret", + }) + secret := webUIRequest(ctx, t, client, http.MethodGet, origin, secretPath, token, nil) + var masked webUIObject + decodeWebUIJSON(t, secret, http.StatusOK, &masked) + secretValue := base64.StdEncoding.EncodeToString([]byte("token-" + contexts[0])) + if !strings.Contains(masked.YAML, "") || strings.Contains(masked.YAML, secretValue) { + t.Fatalf("web UI masked secret leaked value: %q", masked.YAML) + } + + logsPath := webUITargetPath("/api/v1/logs", localops.Target{ + Context: contexts[0], Namespace: "default", Kind: "Pod", Name: "sith-local-ops", + }) + "&tail=20" + logs := webUIRequest(ctx, t, client, http.MethodGet, origin, logsPath, token, nil) + if logs.StatusCode != http.StatusOK || !strings.Contains(string(logs.Body), "cluster="+contexts[0]+" ready") || + strings.Contains(string(logs.Body), contexts[1]) { + t.Fatalf("web UI logs status/body = %d/%q", logs.StatusCode, logs.Body) + } + + execPayload := map[string]any{ + "target": localops.Target{Context: contexts[0], Namespace: "default", Kind: "Pod", Name: "sith-local-ops"}, + "command": []string{"/fixture", "echo", contexts[0]}, + } + executed := webUIRequest(ctx, t, client, http.MethodPost, origin, "/api/v1/exec", token, marshalWebUIJSON(t, execPayload)) + var execution webUIExecResult + decodeWebUIJSON(t, executed, http.StatusOK, &execution) + if strings.TrimSpace(execution.Stdout) != contexts[0] || execution.Stderr != "" { + t.Fatalf("web UI exec result = %#v", execution) + } + + exerciseWebUIEdit(ctx, t, client, origin, token, contexts[0]) + exerciseWebUIForward(ctx, t, client, origin, token, contexts[0]) + + refresh := webUIRequest(ctx, t, client, http.MethodPost, origin, "/api/v1/sync", token, []byte(`{}`)) + if refresh.StatusCode != http.StatusAccepted { + t.Fatalf("web UI explicit refresh status/body = %d/%q", refresh.StatusCode, refresh.Body) + } + process.stop(t) +} + +func exerciseWebUIEdit( + ctx context.Context, + t *testing.T, + client *http.Client, + origin, token, contextName string, +) { + t.Helper() + target := localops.Target{Context: contextName, Namespace: "default", Kind: "ConfigMap", Name: "sith-local-edit"} + path := webUITargetPath("/api/v1/object", target) + current := webUIRequest(ctx, t, client, http.MethodGet, origin, path, token, nil) + var object webUIObject + decodeWebUIJSON(t, current, http.StatusOK, &object) + manifest := strings.Replace(object.YAML, "mode: verified-1", "mode: verified-web", 1) + if manifest == object.YAML { + t.Fatalf("web UI edit source did not contain prior server-applied value: %q", object.YAML) + } + payload := map[string]any{"target": target, "manifest": manifest} + previewed := webUIRequest( + ctx, t, client, http.MethodPost, origin, "/api/v1/edit/preview", token, marshalWebUIJSON(t, payload), + ) + var preview webUIPreview + decodeWebUIJSON(t, previewed, http.StatusOK, &preview) + if preview.PreviewToken == "" || !strings.Contains(preview.Diff, "server dry-run") { + t.Fatalf("web UI edit preview = %#v", preview) + } + + withoutGrant := webUIRequest( + ctx, t, client, http.MethodPost, origin, "/api/v1/edit/apply", token, marshalWebUIJSON(t, payload), + ) + if withoutGrant.StatusCode != http.StatusConflict { + t.Fatalf("web UI apply without preview status = %d, want 409", withoutGrant.StatusCode) + } + payload["preview_token"] = preview.PreviewToken + applied := webUIRequest( + ctx, t, client, http.MethodPost, origin, "/api/v1/edit/apply", token, marshalWebUIJSON(t, payload), + ) + if applied.StatusCode != http.StatusOK { + t.Fatalf("web UI previewed apply status/body = %d/%q", applied.StatusCode, applied.Body) + } + after := webUIRequest(ctx, t, client, http.MethodGet, origin, path, token, nil) + var verified webUIObject + decodeWebUIJSON(t, after, http.StatusOK, &verified) + if !strings.Contains(verified.YAML, "mode: verified-web") { + t.Fatalf("web UI applied object = %q", verified.YAML) + } +} + +func exerciseWebUIForward( + ctx context.Context, + t *testing.T, + client *http.Client, + origin, token, contextName string, +) { + t.Helper() + payload := map[string]any{ + "target": localops.Target{Context: contextName, Namespace: "default", Kind: "Service", Name: "sith-local-ops"}, + "ports": []string{":web"}, + } + started := webUIRequest( + ctx, t, client, http.MethodPost, origin, "/api/v1/port-forwards", token, marshalWebUIJSON(t, payload), + ) + var forward webUIForward + decodeWebUIJSON(t, started, http.StatusCreated, &forward) + if forward.ID == "" || forward.Target.Context != contextName || len(forward.Ports) != 1 || + forward.Ports[0].Local == 0 || forward.Ports[0].Remote != 8080 { + t.Fatalf("web UI forward = %#v", forward) + } + forwarded := webUIRequest( + ctx, t, client, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d", forward.Ports[0].Local), "", "", nil, + ) + if forwarded.StatusCode != http.StatusOK || !strings.Contains(string(forwarded.Body), "cluster="+contextName) { + t.Fatalf("web UI forwarded response status/body = %d/%q", forwarded.StatusCode, forwarded.Body) + } + listed := webUIRequest(ctx, t, client, http.MethodGet, origin, "/api/v1/port-forwards", token, nil) + var forwards []webUIForward + decodeWebUIJSON(t, listed, http.StatusOK, &forwards) + if len(forwards) != 1 || forwards[0].ID != forward.ID { + t.Fatalf("web UI forwards = %#v", forwards) + } + closed := webUIRequest( + ctx, t, client, http.MethodDelete, origin, "/api/v1/port-forwards/"+url.PathEscape(forward.ID), token, nil, + ) + if closed.StatusCode != http.StatusNoContent { + t.Fatalf("web UI close forward status/body = %d/%q", closed.StatusCode, closed.Body) + } +} + +func startWebUI(ctx context.Context, t *testing.T, binary, kubeconfigPath string) (*webUIProcess, string) { + t.Helper() + command := exec.Command(binary, "ui", "--no-open", "--address", "127.0.0.1", "--port", "0") + command.Env = append(os.Environ(), + "KUBECONFIG="+kubeconfigPath, + "XDG_CONFIG_HOME="+filepath.Join(filepath.Dir(kubeconfigPath), "config-home-web"), + ) + stdout, err := command.StdoutPipe() + if err != nil { + t.Fatalf("web UI stdout: %v", err) + } + process := &webUIProcess{command: command, stderr: &bytes.Buffer{}} + command.Stderr = process.stderr + if err := command.Start(); err != nil { + t.Fatalf("start web UI: %v", err) + } + lines := make(chan string, 1) + go func() { + scanner := bufio.NewScanner(stdout) + if scanner.Scan() { + lines <- scanner.Text() + } + close(lines) + }() + timer := time.NewTimer(30 * time.Second) + defer timer.Stop() + select { + case line, open := <-lines: + if !open { + process.stop(t) + t.Fatalf("web UI exited before reporting its address: %s", process.stderr.String()) + } + match := webUIAddressPattern.FindStringSubmatch(line) + if len(match) != 2 { + process.stop(t) + t.Fatalf("web UI address line = %q", line) + } + return process, match[1] + case <-timer.C: + process.stop(t) + t.Fatalf("web UI did not report its address: %s", process.stderr.String()) + case <-ctx.Done(): + process.stop(t) + t.Fatalf("web UI context ended before startup: %v", ctx.Err()) + } + return nil, "" +} + +func (process *webUIProcess) stop(t *testing.T) { + t.Helper() + process.once.Do(func() { + if process.command.ProcessState == nil { + if err := process.command.Process.Signal(os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) { + t.Errorf("interrupt web UI: %v", err) + } + } + if err := process.command.Wait(); err != nil && process.command.ProcessState != nil && + !process.command.ProcessState.Success() { + t.Errorf("wait for web UI: %v\n%s", err, process.stderr.String()) + } + }) +} + +func waitForWebUISnapshot( + ctx context.Context, + t *testing.T, + client *http.Client, + origin, token string, + contexts []string, +) fleetcache.Snapshot { + t.Helper() + deadline := time.NewTimer(45 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + response := webUIRequest(ctx, t, client, http.MethodGet, origin, "/api/v1/snapshot?kind=Pod", token, nil) + var snapshot fleetcache.Snapshot + decodeWebUIJSON(t, response, http.StatusOK, &snapshot) + seen := map[string]bool{contexts[0]: false, contexts[1]: false} + for _, record := range snapshot.Records { + if record.Name == "sith-local-ops" { + seen[record.Cluster] = true + } + } + if snapshot.Coverage.Reachable == 2 && seen[contexts[0]] && seen[contexts[1]] { + return snapshot + } + select { + case <-ctx.Done(): + t.Fatalf("wait for web UI cache hydration: %v", ctx.Err()) + case <-deadline.C: + t.Fatalf("web UI cache did not hydrate both contexts: %#v", snapshot) + case <-ticker.C: + } + } +} + +type webUIResponse struct { + StatusCode int + Body []byte +} + +func webUIRequest( + ctx context.Context, + t *testing.T, + client *http.Client, + method, origin, path, token string, + body []byte, +) webUIResponse { + t.Helper() + request, err := http.NewRequestWithContext(ctx, method, origin+path, bytes.NewReader(body)) + if err != nil { + t.Fatalf("construct web UI request: %v", err) + } + if token != "" { + request.Header.Set("X-Sith-CSRF", token) + request.Header.Set("Origin", origin) + } + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + response, err := client.Do(request) + if err != nil { + t.Fatalf("run web UI request %s %s: %v", method, request.URL, err) + } + payload, readErr := io.ReadAll(io.LimitReader(response.Body, 12<<20)) + closeErr := response.Body.Close() + if err := errors.Join(readErr, closeErr); err != nil { + t.Fatalf("read web UI response %s %s: %v", method, request.URL, err) + } + return webUIResponse{StatusCode: response.StatusCode, Body: payload} +} + +func webUITargetPath(base string, target localops.Target) string { + values := url.Values{ + "context": []string{target.Context}, "namespace": []string{target.Namespace}, + "kind": []string{target.Kind}, "name": []string{target.Name}, + } + return base + "?" + values.Encode() +} + +func marshalWebUIJSON(t *testing.T, value any) []byte { + t.Helper() + payload, err := json.Marshal(value) + if err != nil { + t.Fatalf("encode web UI JSON: %v", err) + } + return payload +} + +func decodeWebUIJSON(t *testing.T, response webUIResponse, status int, destination any) { + t.Helper() + if response.StatusCode != status { + t.Fatalf("web UI response status/body = %d/%q, want %d", response.StatusCode, response.Body, status) + } + if err := json.Unmarshal(response.Body, destination); err != nil { + t.Fatalf("decode web UI response %q: %v", response.Body, err) + } +} diff --git a/tests/e2e/smoke_test.go b/tests/e2e/smoke_test.go index cd5a4c8..2b8d6a1 100644 --- a/tests/e2e/smoke_test.go +++ b/tests/e2e/smoke_test.go @@ -5,8 +5,12 @@ package e2e_test import ( + "bufio" + "bytes" "context" "encoding/json" + "io" + "net/http" "os" "os/exec" "path/filepath" @@ -42,7 +46,6 @@ func TestBinarySmoke(t *testing.T) { {name: "version JSON", args: []string{"version", "-o", "json"}, validJSON: true}, {name: "clusters text", args: []string{"clusters"}, contains: "No clusters found (source: local-kubeconfig)."}, {name: "clusters JSON", args: []string{"clusters", "-o", "json"}, validJSON: true}, - {name: "ui stub", args: []string{"ui"}, contains: "not yet implemented"}, {name: "hub stub", args: []string{"hub"}, contains: "phase-1+"}, {name: "no arguments", contains: "Usage:"}, {name: "help", args: []string{"--help"}, contains: "Usage:"}, @@ -65,6 +68,73 @@ func TestBinarySmoke(t *testing.T) { } }) } + smokeWebUI(ctx, t, binary, kubeconfig) +} + +func smokeWebUI(ctx context.Context, t *testing.T, binary, kubeconfig string) { + t.Helper() + command := exec.CommandContext(ctx, binary, "ui", "--no-open", "--address", "127.0.0.1", "--port", "0") + command.Env = append(os.Environ(), "XDG_CONFIG_HOME="+t.TempDir(), "KUBECONFIG="+kubeconfig) + stdout, err := command.StdoutPipe() + if err != nil { + t.Fatalf("web UI stdout: %v", err) + } + var stderr bytes.Buffer + command.Stderr = &stderr + if err := command.Start(); err != nil { + t.Fatalf("start web UI: %v", err) + } + stopped := false + defer func() { + if !stopped { + _ = command.Process.Kill() + _ = command.Wait() + } + }() + lineReady := make(chan string, 1) + go func() { + scanner := bufio.NewScanner(stdout) + if scanner.Scan() { + lineReady <- scanner.Text() + } + close(lineReady) + }() + var line string + select { + case line = <-lineReady: + case <-time.After(10 * time.Second): + t.Fatalf("web UI did not report startup: %s", stderr.String()) + case <-ctx.Done(): + t.Fatalf("web UI startup context: %v", ctx.Err()) + } + const prefix = "sith ui listening on " + if !strings.HasPrefix(line, prefix) { + t.Fatalf("web UI startup line = %q", line) + } + origin := strings.TrimPrefix(line, prefix) + request, err := http.NewRequestWithContext(ctx, http.MethodGet, origin+"/", nil) + if err != nil { + t.Fatalf("construct web UI request: %v", err) + } + response, err := (&http.Client{Timeout: 5 * time.Second}).Do(request) + if err != nil { + t.Fatalf("request web UI: %v", err) + } + body, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + closeErr := response.Body.Close() + if readErr != nil || closeErr != nil { + t.Fatalf("read web UI index: %v / %v", readErr, closeErr) + } + if response.StatusCode != http.StatusOK || !strings.Contains(string(body), "Sith — Fleet IDE") { + t.Fatalf("web UI status/body = %d/%q", response.StatusCode, body) + } + if err := command.Process.Signal(os.Interrupt); err != nil { + t.Fatalf("interrupt web UI: %v", err) + } + if err := command.Wait(); err != nil { + t.Fatalf("wait for web UI: %v\n%s", err, stderr.String()) + } + stopped = true } func TestUnknownCommandFails(t *testing.T) {