# Spinoza [![go](https://img.shields.io/github/actions/workflow/status/sophotechlabs/spinoza/go.yaml?branch=main&label=go)](https://github.com/sophotechlabs/spinoza/actions/workflows/go.yaml) [![frontend](https://img.shields.io/github/actions/workflow/status/sophotechlabs/spinoza/frontend.yaml?branch=main&label=frontend)](https://github.com/sophotechlabs/spinoza/actions/workflows/frontend.yaml) [![repo](https://img.shields.io/github/actions/workflow/status/sophotechlabs/spinoza/repo.yaml?branch=main&label=repo)](https://github.com/sophotechlabs/spinoza/actions/workflows/repo.yaml) [![go coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/sophotechlabs/spinoza/badges/coverage-go.json)](https://github.com/sophotechlabs/spinoza/actions/workflows/go.yaml) [![web coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/sophotechlabs/spinoza/badges/coverage-web.json)](https://github.com/sophotechlabs/spinoza/actions/workflows/frontend.yaml) [![license](https://img.shields.io/badge/license-FSL--1.1--ALv2-blue)](LICENSE) A self-hosted Kubernetes GUI: Go backend with client-go informers, React frontend, one binary. Runs as a browser tab or a desktop window. [spinoza.tech](https://spinoza.tech) ## Install macOS and Linux: ```sh curl -fsSL https://spinoza.tech/install.sh | sh ``` Windows, in PowerShell: ```powershell irm https://spinoza.tech/install.ps1 | iex ``` The binary goes to `~/.local/bin`, or `/usr/local/bin` as root, and on Windows to `%LOCALAPPDATA%\Programs\spinoza`, which is added to your user `PATH`. `SPINOZA_INSTALL_DIR` overrides all three. Checksums are verified before anything is written. The desktop app lands in `/Applications` on macOS, your launcher on Linux, and an `app` subdirectory on Windows, unless `SPINOZA_SKIP_APP=1`. ```sh spinoza --open # browser open -a Spinoza # desktop window, macOS ``` Archives are on the [releases page](https://github.com/sophotechlabs/spinoza/releases): tarballs for Linux and macOS, zips for Windows. `SPINOZA_VERSION=v1.22.0` pins the installer. Nothing is code-signed, so Windows SmartScreen warns on first run: More info, then Run anyway. Set `SPINOZA_VERIFY_ATTESTATION=1` and both installers check the GitHub build provenance of what they downloaded before writing it. It needs `gh` installed and signed in, so it is off by default. To remove it, on macOS and Linux: ```sh curl -fsSL https://spinoza.tech/install.sh | SPINOZA_UNINSTALL=1 sh ``` On Windows, in PowerShell: ```powershell $env:SPINOZA_UNINSTALL=1; irm https://spinoza.tech/install.ps1 | iex ``` Either takes back the binary, the desktop app and its launcher entry, and on Windows the `PATH` entry too. Your settings and kubeconfigs are left alone, and so is the directory the binary sat in. Needs a kubeconfig. Upgrades, rollbacks and debug containers call `helm` and `kubectl`. ## Every type discovery reports One informer-backed view per type, so CRDs need no per-type code. Snapshot, then deltas over a WebSocket keyed by uid. Field-aware filter completed from what the cluster reported, namespace scope, failing-object badges, bulk restart and delete. ![Spinoza pods table: every pod across all namespaces with its containers, status, restarts, node and live CPU](docs/images/pods.png) ## Clusters Open as many as you like. Each is a tab with its own state, health and history, named and coloured however you want, grouped, and remembered or forgotten between runs. Anything destructive on a protected cluster asks for the name typed out. ![Spinoza with two clusters open as tabs, a ConfigMap selected, and the compare panel docked below showing the lines that differ against the second cluster side by side](docs/images/compare.png) The fleet view answers one question across every open cluster at once: checks, issues, search, Helm releases and GitOps, without switching tab. ## Checks 128 checks over security, reliability and efficiency, decided against the live cluster rather than a parsed manifest, and labelled PSS baseline, PSS restricted, NSA/CISA or CIS Kubernetes Benchmark where a framework covers them. They read workloads, RBAC, networking, storage and your own custom resources, and warn about APIs a coming release removes and certificates about to expire. ![Spinoza cluster checks: findings across the cluster, each rule labelled with its framework, severity and the number of objects that tripped it](docs/images/checks.png) Read them by check, or by control: CIS Kubernetes Benchmark alongside PSS and NSA/CISA, with what fails each control, and — for the controls no rule answers, or that nothing reading a live cluster could — that nothing answers them, and why. Take them away as CSV, as JSON, or as SARIF that whatever already reads SARIF will take unchanged, with a fingerprint per finding so a second run deduplicates against the first. Findings rank by how far the problem reaches, not by rule severity alone. Mute a rule with one of your own, take a baseline and compare a cluster against it, or write a check as a CEL expression. A rule sees the cluster the audit read through `cluster.list(group, resource)` and `cluster.get(...)`, compares quantities with `quantity()`, reads a certificate's dates with `x509.notAfter()`, and has the string, list, set, math, regex and encoder libraries of CEL. ## Issues What is broken now, ranked and self-clearing as the cluster recovers, paged, and available across every open cluster at once. The timeline beside it records what the cluster did. ![Spinoza issues view: what is broken in the cluster right now, ranked, with the reason for each failing workload](docs/images/issues.png) ## Topology Open the graph around one object and it draws what owns it, what it owns, what routes to it and what configures it. Folded by default; the whole cluster is one click away. ![Spinoza topology graph around one Deployment: the Ingress and Service that route to it, the ConfigMap that configures it, its ReplicaSet and pods folded into the node, and the Deployment's own panel open beside it](docs/images/topology.png) ## Compare across clusters Any object against another context side by side, or a whole kind at once with a verdict per object: same, differs by so many lines, or present on only one side. Both sides lose status, managedFields and the rest of what the server assigns, so what differs is what you authored. ![Spinoza comparing every ConfigMap in a namespace across two clusters: a verdict per object with the number of lines that differ](docs/images/kind-compare.png) ## History Every change Spinoza made, kept per cluster, with what changed and when. ![Spinoza history view: a record of the changes Spinoza itself made to the cluster](docs/images/history.png) ## GitOps Flux and Argo CD side by side: cluster sync, controller health, per-kind lists, status tiles. Reconcile, suspend and resume Flux objects. Sync and refresh Argo Applications. ![Spinoza Flux overview: all systems operational, cluster sync from a GitRepository, controller health and resource usage, and counts for appliers and sources](docs/images/flux-overview.png) The dependency graph is laid out by what manages what. Argo's is the app-of-apps, ApplicationSets above what they generate. A node opens in the drawer with its actions. ![Spinoza GitOps dependency graph: a GitRepository linked to the Kustomization it is the source for](docs/images/gitops-graph.png) ## Helm without the binary Releases read from Helm's own storage, either driver. Chart and app version, what your repos offer, revision, status, values, notes, rendered manifest, history. OCI end to end. Upgrade behind a server-rendered manifest diff; rollback and uninstall from the same panel. A Flux-owned release links to its HelmRelease instead of offering an upgrade button. ![Spinoza Helm releases read from the cluster: chart and app versions, the newest chart available, revision, status and when each was last updated](docs/images/helm-releases.png) Selecting a release docks its detail as a panel. Drag it to any side, resize it, collapse it; the placement persists, and `release` and `releaseNs` in the URL carry it through a reload or a back button. Clicking a rendered resource opens that object in its own table with the row selected and the drawer open, while the release stays docked underneath. ![Spinoza Helm releases with one release selected and its detail docked below, listing the ConfigMap and Deployment the chart rendered, alongside upgrade and uninstall](docs/images/helm-resources.png) ## Inspect and edit Metadata, conditions, events and live YAML in Monaco. Schema-aware completion from the cluster's own OpenAPI. Apply sends the object back with the resourceVersion you read, so a draft built on a read the cluster has moved past is refused rather than written over it. Delete names the object you inspected by its UID, so a replacement that reuses the name is left alone. An edited draft survives a background reload, and the browser's Back button asks before it discards one. ![Spinoza ConfigMap table with the inspect drawer open on the object's live YAML in Monaco, with apply, revert and delete](docs/images/inspect-yaml.png) ## Port-forwarding Any pod or service on localhost, no kubectl. Forwards survive navigation, listed and stopped from one panel. ![Spinoza pods table with the inspect drawer open on a running pod: its container port forwarded to a local port with open and stop controls, and the forward listed in the dock below](docs/images/port-forward.png) ## Rollout revisions What a Deployment, StatefulSet or DaemonSet rolled out, which revision is running now, what differs between any two of them once the server's own fields are stripped, and a way back to one. Undo asks the apiserver first, is greyed out with the reason when the cluster would refuse it, and asks for the name typed out on a protected cluster. ## Reserved against used What every namespace and workload asks the scheduler for against what it actually uses, ranked by what could be given back. Usage comes from Prometheus over a window where there is one, from metrics-server otherwise, and where nothing measured it the view says so rather than showing a zero. ## Write actions Scale, rollout restart, undo, cordon, uncordon, drain. Drain shows its eviction plan before it runs: what it evicts, what it leaves, what it blocks and why. Protected clusters require the object name typed out. ![Spinoza drain plan for a node: how many pods it would evict, leave in place and block, the reason for each, and the drain button disabled behind a checkbox](docs/images/drain-plan.png) ## Also - **Cluster overview**: server version, node readiness, allocatable against live usage, pods by phase, recent warning events. - **Exec** over `v5.channel.k8s.io` into xterm. Shell-less images get an ephemeral debug container, on a verdict cached per image digest. - **Logs** per container. Pausing follow stops the scroll, not the stream. - **Metrics**: metrics-server in the tables, CPU and memory history from Prometheus through the apiserver proxy. `--prometheus namespace/service:port` overrides discovery. With no Prometheus to ask, spinoza samples metrics-server itself while the window is open and says so on the chart. - **Update check**: asks spinoza.tech once per run whether a newer release exists, and offers the install command if so. Automatic replacement is disabled because the remote installer script has no independent authenticity proof. `SPINOZA_UNSAFE_SELF_UPDATE=true` restores the legacy download-and-execute path for compatibility and lets mutable remote code run with your user's privileges. - **Saved views**: a filter, its columns, its namespace and its kind kept under a name and reached from the command palette. In cluster mode they are each person's own, and an admin can publish one for everybody. - **Kubeconfigs** added by path, referenced in place, never copied or merged. Contexts grouped per file, listed in `kubeconfigs.json`. `--kubeconfig PATH` replaces the default lookup for one run. - **MCP server**: `spinoza-mcp`, a separate binary giving an agent 21 tools and 3 resources over one cluster. - **Node shell**: a root shell in the node's own namespaces, off until you turn it on. It asks the apiserver whether you may create the pod first, and the pod carries a two-hour deadline. - **Secrets** are masked on screen, one key revealed at a time, and the reveal drops the moment you select something else. The values are in the response the browser received, so anyone allowed to read a Secret can also read it from the developer tools. - **Traffic**: a workload graph from Cilium Hubble flow metrics, read through Prometheus. - **Nine themes**, Borg by default, contrast-gated in CI, plus your own as JSON. Screenshots here are Borg. - **Window size**: built for 1280×720 and up, checked in CI at 1280×720 and 1440×900 and at 200% browser zoom. Narrower than 1280 the workspace scrolls sideways rather than reflowing, and a phone is out of scope. A fresh profile opens the Focused preset — one workspace, side and bottom docks closed; Console opens every dock, and Settings → Panels switches between them. A layout you arranged yourself is never overwritten. ## Cluster mode The same binary serves one cluster to a team: run it as a pod, put it behind your ingress, and point it at your Keycloak, or any other OIDC provider, the way you would point Grafana at one. ```sh helm upgrade --install spinoza deploy/helm/spinoza \ --namespace spinoza --create-namespace \ --set publicURL=https://spinoza.example.com \ --set auth.mode=oidc \ --set auth.oidc.issuerURL=https://keycloak.example.com/realms/main \ --set auth.oidc.clientID=spinoza \ --set auth.oidc.clientSecret=... \ --set auth.adminGroups='{platform-admins}' ``` Signing in is authorization code with PKCE, the ID token verified against the provider's JWKS, and a signed session cookie. Groups from the token map to viewer, editor and admin. The secure chart default uses a read-only workload service account with no Secret access and no impersonation power. Deployments that need Kubernetes RBAC per person can enable impersonation for explicit usernames and groups. Shared feeds then check the signed-in person's exact resource access before returning cached data and recheck it while the feed is open. Proxy-header auth and back-channel logout are both there. It reports on itself the way anything else in the cluster does: `/metrics` in Prometheus exposition, json logs, a request name on every answer that also appears in the log line for that request, and `/readyz` that says which of the catalog, the caches and the state store it is still waiting for — so a new pod does not take traffic while its tables would be empty, and a restart costs a reconnect rather than an error. Every change it makes goes out as a structured log line as well as into the trail, and can be exported. Terminal sessions can be recorded when a deployment asks for that. The checks can run on a timer and post a summary when posture moves. Full guide, including what changes when it runs this way: [docs/cluster-mode.md](docs/cluster-mode.md). Which account acts in each mode, what a greyed-out feature is waiting for, and the next step: [docs/modes.md](docs/modes.md). ## Security On your own machine it binds loopback only and rejects non-local Host or Origin. Every route and both WebSockets require the token minted for that run, as the `X-Spinoza-Token` header, a `token` query parameter, or the cookie the page keeps. `--token-file PATH` writes it at mode 0600. Exits when the last view closes. Outbound: your apiserver, the chart repos you configured, and one request per run to spinoza.tech asking whether a newer release exists. In cluster mode the update check is off, and the only extra outbound is your identity provider. ## Develop Toolchain pinned in `mise.toml` (Go 1.27, Node 24); `mise install` gets them. The container image builds with the same Go. ```sh just deps # frontend dependencies, once just run # build the frontend and the binary, then start just dev-api # Go server on :34115 just dev-web # Vite, proxying to the API just rund # build and open the desktop app ``` Vite serves its own `index.html` without the token, so open `http://localhost:5173/?token=`. The binary embeds the built frontend; `just stub-assets` writes a placeholder for the Go-only recipes. Every test recipe takes an optional test name and an optional path: ```sh just test-be TestScaleRejectsNegative ./internal/actions/... just test-fe 'renders the empty state' test/components/History.test.tsx just test-e2e 'the view says what it is for' specs/history.spec.ts ``` Either argument can be given alone. A filtered Go run writes no coverage profile. `just check` before pushing. `just ci` runs everything CI does: cross-compilation, the coverage gate, `govulncheck`, dead-code and unused-dependency checks, a bundle-size budget, secret scanning and SAST. ## Architecture Discovery builds a resource catalog. Each subscribed GVR gets a dynamic informer whose cache is projected into table rows; a WebSocket sends one snapshot then deltas keyed by uid. Informers strip managedFields before caching. Exec and port-forwarding get their own transports: a binary WebSocket carrying the Kubernetes exec channel protocol, and a process-global forward registry independent of any request. The React app is embedded with `embed.FS`. One HTTP and WebSocket server backs both the browser tab and the desktop window, and you move between them mid-session. Built on client-go v0.36, so Kubernetes 1.35 to 1.37 by skew policy. Runs in production against k3s v1.36. ## License [FSL-1.1-ALv2](LICENSE): use, modify and redistribute it for anything except a commercial product that competes with Spinoza. Each release turns into Apache 2.0 two years after it ships. It comes as is, with no warranty of any kind and no liability on Sophotech s.r.o. for what it does. `spinoza --license` prints the full text, the release archives carry it, and the installers leave a copy on disk.