Files
zeekay a7ed7a7c69 Name the thing instead of calling it a door
One word stood for several: the MCP server, one address that accepts requests,
the single way in, and the boundary a credential is validated at. Each site now
names its own sense — endpoint, entry point, the MCP server, the edge, address,
relay — or, where none of those fit, takes the word the surrounding code already
uses.

Prose and comments only, 1:1 line for line. Identifiers keep their names: CSS
classes, DOM ids, struct fields, enum variants, config values and the doc
comments that must open with the identifier they document.
2026-08-23 13:50:00 -07:00

191 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# hanzoai/k8s — deep notes
The one Kubernetes owner in the fleet. Read this before changing anything here; the README
is the product surface, this is the reasoning.
## Why the repo exists (and why a library would not have worked)
Nine places in `hanzoai/cloud` built a Kubernetes client. Measured, not estimated:
| Holder | Credential | Cluster it assumed |
|---|---|---|
| `apps/platform` ×2, `apps/deploy` ×2, `apps/provisioning`, `apps/validators`, `apps/cron` | in-cluster ServiceAccount → `KUBECONFIG` | one ambient |
| `apps/ml` | in-cluster SA, re-scoped to a token file; federated via `fleet.DynForOrg` | ambient **or** the org's BYO |
| `apps/fleet` | **KMS-sealed kubeconfig** per org+project | N registered BYO |
| `apps/admin/infra` | **`DO_API_TOKEN` → a cluster-admin kubeconfig per cluster** | N DOKS clusters |
| `hanzoai/ai/cluster` | **kubeconfig in a database row**, fed to an apply-any-YAML function | one global mutable singleton |
Two of those are the reason this is a service and not a shared package:
1. **`ai/cluster/client.go:210 deployResource`** decoded arbitrary YAML, resolved the GVK
through a discovery `RESTMapper`, and create-or-updated **any kind the cluster served**.
With a DB-sourced kubeconfig, one edited row reached any object on any cluster. It is
DELETED, not ported. Its callers go through `resources.apply` with a named kind.
2. **`admin/infra`** held cluster-admin on every cluster on the house account to perform
nine operations. `nodes/proxy get` alone is arbitrary kubelet access.
A library cannot fix either, because the defect is not the code — it is that N binaries
hold cluster credentials. One owner, named ops, credentials in KMS, consumers ASK.
The **deciding** argument, though, is neither of those. It is that **lux runs its own
`lux-k8s`**: an org's resources may live on a different cluster from Hanzo's, plus BYO for
customers. A single-cluster client, however cleanly factored, cannot express that and would
have been rewritten. Hence multi-cluster from the first line.
## The model
```
Cluster{Name, Org, Provider, Endpoint, Nodes, NvidiaGPU, AmdGPU, Namespaces, Registered, Default}
```
- **No Kubeconfig field and no Credential field.** The ref is DERIVED —
`orgs/<org>/k8s/clusters/<name>/kubeconfig` — because a ref stored as data could be
edited to name another org's material. That is the same defect as letting a caller name
its own org, one level down.
- **`Org` is set from the validated caller**, never from an argument, and appears only on
replies (where it is a property of the thing described).
- **`Namespaces`** bounds a registration made with a scoped credential on a shared cluster.
Empty means the whole cluster, which is what an org registering its own cluster gets.
`registry.Reach(ctx, name) (*Bound, error)` is the only entry point:
1. `Tenant(ctx)` — zip's full rule (validated user claim, non-empty org, under
`MaxOrgLen`) plus one thing zip cannot know: the org becomes a KMS ref segment, so a
value containing `/` or `..` is refused here.
2. the name is looked up in **that org's index only** → `ErrNoCluster` (404) otherwise.
3. the credential is resolved from KMS over the call plane, carrying the caller, so KMS
re-applies its own `orgs/<org>/` rule.
4. `SafeRESTConfig` → typed + dynamic + metrics clients, cached per `org/name`.
`Bound.Scope(ns)` / `Bound.One(ns)` / `Bound.WholeCluster()` are the namespace gate, and
every namespaced op goes through one of them. A named namespace outside the bound is
REFUSED rather than silently narrowed — a caller that asked for the wrong thing must be
told, or it will believe an empty answer.
## What a "grant" is
An org may reach a cluster it **owns**. There is no grant table, and that is a decision
rather than an omission: cross-org credential sharing is refused by KMS by construction (a
call acting for B asking for `orgs/A/…` is forbidden), so a grant table would have been a
second authority that could only ever disagree with the first.
Granting org B access to a cluster A owns is therefore: seal a scoped ServiceAccount
kubeconfig **under B**, and register it in B's own scope with a `namespaces` bound. That is
one existing verb, no new concept, and it is honest — to reach a cluster you must hold a
credential for it, sealed in your own org's KMS.
The platform's own path for a tenant on the shared cluster is the same call, made by the
provisioning job with `zip.WithCaller` stating the tenant it acts for.
## The 36 ops
`plane/plane.go` is the whole contract. Groups: `clusters` (5), `nodes` (3), `namespaces`
(4), `pods` (2), `volumes` (4), `workloads` (8), `jobs` (3), `resources` (5), `access` (1),
`metrics` (1).
Each is registered TWICE from ONE handler: on the app (HTTP, which projects OpenAPI + an
MCP tool + a CLI command) and on `app.Peer()` (the fleet's 0600 socket, invoked by op
token through `zip.Ask`). The registrations are written out as direct `zip.Get` /
`zip.Post` calls with literal paths **deliberately** — zipdoc reads the AST for exactly
that shape, so a registration wrapped in a helper is a registration whose doc comment
reaches neither the OpenAPI description nor the MCP tool description.
Design notes worth keeping:
- **`resources.watch` is a bounded read, not a stream.** A stream cannot cross the call
plane as a value; rendering one (SSE, a websocket) is the edge's concern. It returns the
changes within `waitSeconds` and the cursor to resume from, and `expired` is the field
that matters — a too-old cursor rendered as an empty page is how a watch-backed board
silently stops updating.
- **`nodes.cordon` is one op over three calls** (patch `spec.unschedulable`, list the
node's pods, evict each). There is no generic node patch, because one would let a caller
write any field of any node's spec. The drain evicts rather than deletes, so a
PodDisruptionBudget refusal is the system working.
- **`volumes.expandClaim` takes `gi`, not a patch.** Growing the claim is the one way that
leaves claim, PV, device and filesystem agreeing. Nothing deletes a claim.
- **`namespaces.delete` is separately gated** and refused for a bounded registration: it
garbage-collects every object in the namespace, claims included.
- **`nodes.volumeStats` is its own op** so `nodes/proxy` can be dropped from every other
caller's grant. It is the only source of volume fill we have (no cloud provider exposes
it; metrics-server is absent from most of our clusters) and it reports `nodesRead` /
`nodesTotal`, because a claim with no row is UNMEASURED, which is a different fact from
empty.
- **`metrics.pods` reports `available`.** A cluster with no metrics-server rendered as
zeroes tells an operator a busy namespace is idle.
- **`clusters.status` probes the version endpoint**, not a namespace list: a registration
bounded to one namespace would fail a namespace list and look dead while being healthy.
## Secrets — the decision
There is **no secrets op**, and the absence is the answer to the question the measurement
report left open.
Three consumers wrote cluster Secrets (`platform/secrets.go`,
`provisioning/addon_inject.go`, `ai/cluster.ensureHfTokenSecret`) and one read one back.
An RPC that reads a tenant Secret is a credential-exfiltration endpoint however carefully
it is gated, and the standing rule is that credentials live in KMS.
The mechanism already exists in-cluster: **`KMSSecret`** (`secrets.lux.network/v1alpha1`)
names a KMS ref, and the kms-operator resolves it into a mounted Secret. It is in the
closed kind set, so `resources.apply` with `kind: KMSSecret` is the write-only projection
the report asked for — no new op, nothing returned, and the material never enters this
process. `platform/secrets.go`'s read-back must be re-sourced from KMS.
## Package weight, stated plainly
```
binary 630 packages zip 257 · k8s.io 276 · sigs.k8s.io 10 · own 4 · rest stdlib/shared
plane contract 1 package stdlib only
goja / esbuild 0 zip >= v1.18.7 makes the JS toolchain opt-in
OTLP 0 telemetry rides middleware.Telemetry, nil-safe
kustomize 0 rendering is a deploy concern; it never enters here
```
**630 is not ~260 and never could be.** client-go's typed clientset alone is ~279 packages,
and the typed client is required: `EvictV1` (a PDB-respecting drain), `GetLogs`, and the
kubelet `nodes/proxy` read have no dynamic-client equivalent. The measurement report
predicted 560–600 and said to say so up front rather than discover it at the end; the
measured answer is 630.
What this repo does control is that **nobody else pays it**. The same ~286 packages were
linked into four binaries; the consumer-facing cost is now one package.
## Operational notes
- **RBAC**: `rbac.yaml` is the union ceiling, gated against `ops.Kinds()` by the suite. On
a shared cluster do NOT bind it to a tenant — bind a namespaced Role and register the
cluster with a `namespaces` bound.
- **Migration from `apps/fleet`**: fleet sealed BYO kubeconfigs at
`<org>/fleet/clusters/<name>/kubeconfig` (no `orgs/` prefix, sharded by project). This
repo derives `orgs/<org>/k8s/clusters/<name>/kubeconfig` and does not read the old
shape — deriving the ref is what makes a row unable to name foreign material, so a
compatibility branch would give that property up. Migration is therefore a one-time
re-registration per BYO cluster (or a one-shot copy old-ref → new-ref inside KMS), and it
must happen in the change that removes fleet's registry.
- **Project sub-scope is gone on purpose.** fleet sharded by org+project with a
backward-compatibility seam that kept default-project keys byte-identical. This is a new
ref namespace with no existing key to stay identical with, and a cluster belongs to an
org, so there is one key shape and no seam.
- **`K8S_ALLOW_PRIVATE_HOSTS`** disables the non-routable-apiserver rejection. Tests only.
- **`plane.KMS`/`KMSGet`/`KMSPut`** are the only tokens here this repo does not own. They
are declared identically in cloud's own plane package; when HIP-0106 migration step 2
extracts `github.com/hanzoai/plane`, both collapse into that module and these three
declarations are deleted in the same change. One place to move.
## Open, and needing z's call
1. **DOKS provision/destroy stays with visor.** `POST`/`DELETE /v1/k8s/clusters` in visor
today provisions and destroys DigitalOcean clusters on the house account — a
cloud-provider spend action, and providers are visor's job. So `/v1/k8s/clusters` here
is the REGISTRY (list/get/register/deregister); visor keeps provider-side creation under
`/v1/compute/…` and calls `clusters.register` when a cluster comes up. This is the one
place "`/v1/k8s/clusters` survives and moves" and "cloud PROVIDERS are visor's job"
pull in opposite directions, and it is flagged rather than guessed.
2. **`/v1/clusters` removal is six routes, not a rename.** visor registers `GET`, `POST`
(BYO attach), `DELETE /:id` (BYO detach), `POST /:clusterId/pools`,
`POST /:clusterId/pools/:poolId/scale`, `DELETE /:clusterId/pools/:poolId`. The node-pool
trio is provider work and follows (1). `POST /v1/clusters` — the BYO kubeconfig attach —
is the single most important operation in the whole set and lands here as
`clusters.register`. Every caller (console, `hanzo clusters …`, SDKs, docs) moves in the
same change; forward-only, no alias.