# Control-plane handover: durable ownership and zero-downtime k3s swaps Research + design, 2026-10-11. Input to plans 0006 (M3-CLUSTER-SCOPES, M3-SERVE-GATEWAY-KCP, M5-CUTOVER) and 0007 (M6-GATEWAY-REPOPULATE). Nothing here is decided until the user answers section 8.4. Answers user-decisions.md line 17 ("Cutover / control-plane continuity: NOT decided"). User requirement (2026-10-11): the gateway tracks durably which clusters own which market-rented VMs, so after any restart/upgrade the nodes re-join their control planes; ideally zero downtime for clusters by bringing up a new k3s control-plane process and shutting down the old one. Already decided (not reopened, refined only where marked): gateway + cluster groups run as transient services in a lingering user systemd manager; each cluster's postgres is a Postgres object with its own unit; the cluster group (kine, k3s, bao, dumbpipe, ESO, CCM) is its own unit so a core upgrade does not restart control planes; market node loss = Node NotReady 90 s -> released and replaced. Evidence tags: **[M]** measured on hat-8 for this document (stand-in processes only, section 2.1); **[S]** upstream source or docs, URL in Sources; **[C]** this repo, file:line; **[U]** unverified, must be measured before it is relied on. --------------------------------------------------------------------------------------------------- ## 0. Answer in one screen 1. **k3s HA on a shared external datastore is a working blue/green mechanism today** [M]. A second `k3s server --disable-agent` with the same token file, pointed at its own kine on the same postgres, reconciled the bootstrap data from the datastore, came up with byte-identical CAs and service-account key, served the FIRST server's admin client certificate, and was answering in 2.2 s. With a 100-line L4 front holding the stable port, switching the front and SIGTERMing the old server gave **0 failed `/readyz` polls of 515 (200 ms cadence), 0 failed node-lease heartbeats, and the (fake) Node stayed Ready** through a 16 s controller-manager leader handover. 2. **The agent load-balancer problem in the brief does not exist here**, by construction: every k3s of a market cluster advertises the guest-local link address (`--advertise-address` = the address the guest's own `socialweb-apiserver-link` binds on `lo`) and the same advertise port. The only address an agent can learn from the `kubernetes` EndpointSlice is the one it already dials. The invariant to keep is "every slot advertises the SAME address AND the SAME port" [M, C]. 3. **The link is not swapped for a k3s swap.** A per-cluster L4 front takes over the `k3s-secure` lease port (the port dumbpipe targets, the guest's `apiserverPort`, `status.URL`, every kubeconfig). k3s slots move to new leased ports behind it. Guest tickets, node secrets, kcp mounts and kubeconfigs never change. dumbpipe is re-started only for its own upgrades, and iroh's relay already promotes the newest connection of a duplicated endpoint id [S]. 4. **Hazards found that the plans do not name:** (a) the k3s `kube-system/k3s` leader lease uses the OS hostname as identity, so two servers on one host BOTH lead k3s's own controllers during the overlap [M, S]; (b) the market kube-scheduler runs `leaderElect: false` because "one k3s server per cluster: nothing to elect" [C] - false the moment two slots overlap; (c) k3s exits 0.1 s after SIGTERM [M], so in-flight exec/attach/log streams (the GitLab runner's job stream) break on any swap - zero API downtime is not zero stream disruption; (d) users reach a tenant cluster THROUGH the kcp mount [C], so a kcp restart is a tenant API outage even when every k3s stays up. 5. **Durable ownership:** NodeClaim (`status.contract` + `status.grant`) plus the cluster vault is enough for Joining/Running nodes; Bidding/Provisioning are lost only because the per-contract secrets-server keypair is in memory [C]. Add a per-claim ingress key in OpenBao and a served-state on the claim; add a host-local per-cluster control-plane record (active slot, versions, ports, units) that adoption reads without kcp. 6. **Recommendation:** blue/green slots behind a front for every recurring k3s/kine/supervisor upgrade (new items M3-APISERVER-FRONT, M3-CP-SLOTS); members as units under a per-cluster slice instead of one supervisor unit; swap a cluster when idle if possible, else blue/green; for the ONE-TIME container->host cutover, a **guarded cold restart** (measured on the market network first, NODE-LOSS clock rebased to "path up") rather than a host-side takeover of a live container - cost/risk in section 8.3. --------------------------------------------------------------------------------------------------- ## 1. Current topology (what exists on main today) ### 1.1 The path from a guest's kubelet to its tenant apiserver ``` guest (bidder host, cloud-init user_data) gateway (container today) ----------------------------------------- ------------------------------------- kubelet / meshd / k3s agent -> 127.0.0.1:6444 k3s agent client LB (supervisor+apiserver) -> ADVERTISE_ADDRESS:APISERVER_PORT (address put on guest lo) socialweb-apiserver-link.service dumbpipe connect-tcp --addr ADV:PORT ==iroh/QUIC==> dumbpipe listen-tcp IROH_SECRET= --host 127.0.0.1: --ipv4-addr 0.0.0.0: -> k3s server --disable-agent --https-listen-port --advertise-port --advertise-address ADV -> kine -> postgres ``` - Guest side [C] `socialweb-computer-k8s/lib/cloud-init-plugin-k3s-join/mod.ts`: apiserver link script `:366-370` (`exec dumbpipe connect-tcp --addr "${ADVERTISE_ADDRESS}:${APISERVER_PORT}" "${APISERVER_TICKET}"`), its unit `:555-561` (`Restart=always`, `RestartSec=5`, `StartLimitIntervalSec=0`, `:525`); the agent `:585` (`k3s agent --server https://${ADVERTISE_ADDRESS}:${APISERVER_PORT} --token-file ...`); node-secrets writes ADVERTISE_ADDRESS/APISERVER_PORT/APISERVER_TICKET from the KV entry `:313-342` and refuses non-IPv4 / non-digit / non-[a-z0-9] values `:44-45`; the OpenBao link `:354-357`; meshd `:112-125` (`--kubeconfig /var/lib/rancher/k3s/agent/kubelet.kubeconfig`, i.e. it rides the agent LB, and its own iroh secret in `StateDirectory=socialweb/meshd`). - Gateway side [C]: `internal/clusterlink/clusterlink.go:185-191` (`Argv`: `listen-tcp --host 127.0.0.1: --ipv4-addr 0.0.0.0:`); `internal/clusterprovider/links.go:104-131` (targets = k3s secure port and vault port, appended after every stack member), `:154-173` (seed read from the cluster vault in `Prepare`, passed only as `IROH_SECRET` env), `:181-211` (gate opens when the printed ticket names the vault key). - k3s argv [C] `internal/clusterstack/clusterstack.go:281-317`; ports `internal/clusterprovider/stack.go:271-278` (`advertisePort = listenPort`, one `k3s-secure` lease answers both) and the gate `internal/clusterprovider/steps.go:164-174` (`AssertMarketAdvertiseInvariant`, `/readyz`, then the first kube-system Deployment). `k3s-secure` is width 2 (listen + listen+1) `internal/portallocator/portallocator.go:75-89`. ARCHITECTURE section 8 records why advertise and listen were made one port: split, the EndpointSlice named a port nothing on the guest bound. - kine argv `clusterstack.go:319-329` (`--endpoint --listen-address 127.0.0.1:`); k3s dials it as an etcd endpoint (`--datastore-endpoint http://127.0.0.1:`, `stack.go:37,157`), so k3s runs in its "external etcd" mode against kine. ### 1.2 Durable vs per-process | Item | Where | Durable? | |---|---|---| | Link iroh key (apiserver, openbao) | cluster vault KV `socialweb-links/` (`clusterlink.go:113-146`, CAS-0 first write, never regenerated) | yes - survives every restart while the vault data lives | | Published ticket | `ClusterLink.status.ticket` = `StableTicket(key)`: id only, no relay, no addrs (`clusterlink.go:163-167`) | yes - function of the key alone | | Printed ticket (relay URL + direct addrs) | dumbpipe stdout | per process, NOT published | | Link UDP port | lease `apiserver-link`/`openbao-link` in leases.json | yes, but the published ticket does not carry it (discovery resolves addrs) | | Node join secret | cluster vault `socialweb-nodes/data/`: token `K10::server:`, nodePassword, serverCA, apiserverTicket, advertiseAddress, apiserverPort (`internal/clusterprovider/nodesecrets.go:77-84`) | yes; read once by the guest's oneshot node-secrets | | k3s token | `/token` (`stack.go:33-35`), passed `--token-file` | yes - and it is the PBKDF2 passphrase of the bootstrap data in the datastore [S] | | k3s CA, SA key, bootstrap | encrypted in kine/postgres + `/server/tls` | yes [S, M] | | Ports | leases.json (`SOCIALWEB_CLUSTERPROVIDER_LEASE_PATH`, `deploy/container-entrypoint.sh:46`) | yes | | Advertise address | gateway constant (`SOCIALWEB_CLUSTERPROVIDER_ADVERTISE_ADDRESS`) | yes | | Postgres data + password | `/postgres`, `postgres.pw` (`postgres.go:22-40`); adopted if already serving (`postgres.go:116-122`) | yes | | Vault storage | postgres (`internal/clusterstack/openbao.go:24-35`, `disable_clustering = true`, no `ha_enabled`) | yes; seal via the provider's seal file | | Door secrets server | fresh Secp256k1 keypair + ingress subdomain PER CONTRACT, in memory (`atproto-market/lib/guest-capability-secrets/mod.ts:50-75`); grant installed in memory at onContract | **no** - this is why Bidding/Provisioning claims die with the door | | Provider in-memory | mints, readySatisfied (`provider.go:129-136`), loss clock fallback "first seen" when lastTransitionTime is zero | no | | apiserver TCP conns, agent tunnels, watches | processes | no | ### 1.3 ClusterLinks and meshd `ClusterLink -apiserver|openbao` (spec cluster/service/targetPort, status phase, endpointId, did, ticket) is written Listening by a reporter goroutine with retries (`links.go:213-240`); on listener exit it writes Failed, or Pending "process group was stopped" when the group was reaped. A link that dies after bring-up is NOT restarted until the next bring-up (ARCHITECTURE section 5 "The cluster links", stated limits). meshd is per node, guest-side, iroh-keyed in the guest's own state dir, and reaches the apiserver through the agent LB, so it inherits the same path as the kubelet and needs nothing from a control-plane swap. ### 1.4 How ownership is recorded - NodeClaim (kcp, cluster's home workspace) `api/v1alpha1/types_nodeclaim.go:60-80,186-205`: `status.contract` {vmUri, rfpUri, acceptUri, receiptUri, receiptCid, submitEventRef, phase, vmName, requesterDid, network, released}, `status.grant` {issuer, subject, audience, role}, `status.phase` Pending|Bidding|Provisioning|Joining|Running|Failed|Released. Name = `-node` = tenant Node name. Plan 0005 M2-CLAIM-RECEIPT: "the claim is the durable record a restarted door resumes from". - Market records (requester's PDS: rfp, accept, vm.delete; winner's repo: receipt). The claim mirrors their URIs; the records are evidence, the claim is the index. - Tenant `Node` (kine/postgres of the cluster) + `kube-node-lease/` - the liveness truth. ### 1.5 Restart behaviour today - **Provider** (plan 0005 M3-NODE-LOSS provider half, acd218f; ARCHITECTURE section 9 "A restarted provider never reaps; it adopts"): shutdown leaves market nodes; startup adopts every live claim (`internal/nodemarket/adopt.go:15-23`) before the cluster's CCM starts; a Failed claim no process holds is deleted so the door releases it (b2624c6). Loss rule: Ready False/Unknown with `now - lastTransitionTime >= 90 s` (`cmd/socialweb-clusterprovider/nodes.go:194-223`), never-Ready nodes get a 2 m join grace (`config.go:51`). - **Provider vs a running group**: an apiserver answering at `status.URL` with no group held is "Foreign ... left alone" (`provider.go:164-168`); the restart reap scans leased ports and argv (`cmd/socialweb-clusterprovider/startup.go:95-200`), adopts the store (`EnsureStore`), reaps unowned survivors, refuses unattributable holders. - **Door** (`socialweb-computer-k8s/lib/socialweb-computer-k8s-node-market/mod.ts`): Joining/Running claims are adopted by not touching them (`pass()` `:497-519` acts only on Pending, deletion, and `ORPHANED_PHASES = {Bidding, Provisioning}` `:90`); orphans are marked Failed with "the secrets server its guest was told to use went with the old door" (`:487-495`) and released on deletion from the receipt on the claim. `stop()` releases nothing (`:533-539`). ### 1.6 What dies on a container restart today Everything gateway-side: `container-entrypoint.sh:283-341` supervises rootvault, kcp, gateway, socat TLS front, clusterprovider, door, and exits 1 when any child exits (`:328-340`); every process-group member is spawned with `Pdeathsig: SIGKILL` from a locked thread (`internal/processrunner/processrunner.go:121`, `internal/hostos/sysproc_linux.go:14-15`, plan 0005 M6-LIMITS) so k3s, kine, bao, ESO, CCM and both dumbpipe links die with the provider; per-cluster postgres has no Pdeathsig (adopted if serving) but dies with the container. What survives: guests and their `Restart=always` link/agent units, market records, kcp's root and every cluster's postgres/vault data on the volume, leases.json. Cold start after restart: postgres, bao + unseal, kine, k3s, controllers, CCM, ESO, links - measured 22.68 s to Ready at zero nodes with ESO in the group (versions.json `external-secrets.measured`). Note: the dev script `deploy/run-container.sh:11-19` publishes ports with `-p`; prod (per the user) runs `--network host`, which section 5c relies on. --------------------------------------------------------------------------------------------------- ## 2. k3s HA with an external datastore as the blue/green mechanism ### 2.1 The measurement [M] Stand-ins only, in the scratchpad: PostgreSQL 18.6 (`initdb --auth=trust`, 127.0.0.1:35432, no unix socket), two kine v0.17.1-dev (35001, 35002) on the SAME database, k3s v1.36.4+k3s1 x2, a Python asyncio L4 front (127.0.0.1:35443, backend read from a file per new connection). No agent, no guest, no container. Server flags copied from `ServerArgv` (market form): `--disable-agent --disable=traefik --egress-selector-mode cluster --advertise-address 10.77.0.1 --advertise-port 35443 --bind-address 127.0.0.1 --https-listen-port --token-file --tls-san 10.77.0.1 --flannel-backend=none --cluster-cidr=10.42.0.0/16`, per-slot controller ports. | Reading | Result | |---|---| | blue cold start to first kube-system Deployment | ~1-2 s (zero nodes, empty DB) | | green (second server, own data dir, own kine, same token, same PG) serving with BLUE's admin client cert | 2.2 s after start | | green log | `Reconciling bootstrap data between datastore and disk` | | sha256 server-ca.crt / client-ca.crt / request-header-ca.crt / service.key / server/token, blue vs green | identical, all five | | `kubernetes` EndpointSlice, blue only / both / after blue stopped | `10.77.0.1:35443` throughout (the advertise pair; k3s passes `--advertise-port` to its apiserver) - no removal event seen | | kube-apiserver binds | `127.0.0.1:`, `[::1]:`, `127.0.0.1:` | | fake Node `fake1` (Ready=True patched) + Lease renewed every 2 s through the front | Node Ready the whole run, `lastTransitionTime` unchanged | | switch front to green, wait 5 s, SIGTERM blue | blue exited in **0.10 s** | | `/readyz` (authenticated) through the front every 200 ms across the swap | **515 polls, 0 non-200** | | heartbeats through the front | **0 failures** | | `kubectl get node -w` through the front | exited 0.04 s after the SIGTERM (connection closed; a reflector would re-watch) | | leases after the swap | kube-controller-manager acquired by green 16.4 s after SIGTERM (15 s lease, no release on cancel); kube-scheduler 17.9 s; k3s-cloud-controller-manager 18.1 s; `leaseTransitions: 1` each | | `kube-system/k3s` lease | holder `hat-8` (the hostname) for BOTH servers; green logged `Successfully acquired lease lock=kube-system/k3s` while blue held it; a third start with `--node-name slot-blue` did the same - identity is the hostname, not the node name | | restart in place (blue, warm data dir, same kine) | serving in 1.7 s | | kine x2 on one PG | no errors in either log; schema `name text COLLATE "C"`, unique `(name, prev_revision)` | | k3s process title | rewritten to `k3s server` (argv not visible in /proc cmdline) | All processes were stopped and the data dirs deleted; scripts and logs remain in `scratchpad/handover/` (front.py, start.sh, heartbeat.sh, poll.sh, logs/). ### 2.2 What the second server must share, and what it must not Must share: the token (same `--token-file`; bootstrap data is encrypted with it [S k3s token]); `--datastore-endpoint` reaching the same database (through its own kine or the same kine); cluster-wide flags k3s requires equal on all servers: `--cluster-cidr`, `--service-cidr`, `--cluster-dns`, `--cluster-domain`, every `--disable`, `--disable-helm-controller`, `--disable-kube-proxy`, `--disable-network-policy`, `--secrets-encryption` [S k3s HA]; for us also `--flannel-backend=none`, `--egress-selector-mode cluster`, the audit flags, `service-account-issuer` and `api-audiences` (tokens minted by one slot must verify on the other), `--advertise-address` and `--advertise-port` (section 2.5), `--tls-san` (the advertise address). Must NOT share: `--data-dir` (server/tls, server/cred, kubeconfigs and the dynamic listener cache are per server); `--https-listen-port` and its +1; the three controller secure ports; the kine listen port when slots carry their own kine. The admin kubeconfig k3s writes names the slot's listen port, so every consumer (provider admin, CCM, ESO, the kcp mount via `status.URL`) must name the FRONT port instead; client certificates are interchangeable because the client CA is shared [M]. CA reuse: automatic. A fresh data dir pulls CA, SA key and request-header CA from the datastore; disk newer than datastore is reconciled by k3s at start [M log line, S]. Node password: k3s keeps node passwords as `kube-system/.node-password.k3s` Secrets in the datastore, so both slots validate the same guests. The server-side `/etc/rancher/node/password` is read/written by every `k3s server` (versions.json k3s invariant) and its value is never checked for a `--disable-agent` server, so two slots sharing the file is harmless [C versions.json]. ### 2.3 Leader election during the overlap - kube-controller-manager, kube-scheduler, k3s-cloud-controller-manager: client-go leases with unique identities (`hat-8_`); handover took the full 15 s lease duration after SIGTERM [M] (kcm default `--leader-elect-lease-duration 15s` [S kcm]). During those ~16 s no controller loop runs: no NotReady marking, no scheduling, no GC. Acceptable; shorter leases are a tuning option. - **k3s's own leader-elected controllers** (wrangler `leader.RunOrDie` with `id := os.Hostname()`, 45 s lease [S wrangler leader.go:22,42]; k3s registers its apiserver controllers there [S k3s server.go]): two slots on one host share the identity, so both run them for the whole overlap [M]. With equal versions this is duplicate idempotent applies (addon manifests, helm controller). With a k3s VERSION change, blue re-applies the old bundled addon manifests while green applies the new ones until blue stops. Mitigation: keep the overlap to "green ready -> switch -> stop blue" (seconds); later option: run each slot with a private UTS namespace/hostname [U for a user-manager unit]. - **kube-scheduler**: the market scheduler config sets `leaderElect: false` (`internal/clusterstack/scheduler.go:51-52`, commit 1cbf945: "One k3s server per cluster: nothing to elect", made to avoid a 15 s wait on the previous incarnation's lease). With two slots both schedulers are active once a node exists: binds race (the loser gets a conflict) and preemption can evict twice. Fix: `leaderElect: true` with a short lease (e.g. leaseDuration 4s, renewDeadline 3s, retryPeriod 1s): worst-case first-pod delay drops from 15 s to ~4 s instead of 0 [U measure]. k3s starts the scheduler only when a node exists (no `kube-scheduler` lease appeared at zero nodes, it appeared after the fake Node) [M]. - ESO and the socialweb CCM run `--enable-leader-election=false` / no election (`clusterstack.go:243`, versions.json): they are single members of the group and must stay single; they are NOT duplicated per slot - they dial the front and ride the swap. - kube-apiserver identity lease is named from the hashed hostname [S k8s leases], so both slots share `apiserver-` [M]; nothing in this design reads it. ### 2.4 Version skew for rolling server upgrades - k3s: "upgrade server nodes first one at a time, then any agent nodes" [S k3s manual upgrade]. - Kubernetes: HA kube-apiservers within one minor of each other; kcm/scheduler/ccm not newer than the apiserver they talk to; kubelet not newer than the apiserver and up to three minors older [S skew policy]. In a k3s slot each kcm/scheduler talks only to its own apiserver over loopback, so the only cross-version contact is shared storage and the overlap window. - Guests never upgrade in place (nodes are disposable; a new node takes the door's current k3s pin), so the rule for the door is: control plane minor >= guest minor, and <= guest minor + 3. - One minor at a time; patch releases freely. Rollback to the previous MINOR after green has written is not supported by Kubernetes or k3s; rollback is to the previous patch of the same minor only. A minor bump therefore needs a postgres snapshot (pg_dump of `k3s` + `openbao`) taken right before the switch. - Fields: a newer apiserver may write fields an older one prunes on a later update through it; the overlap is seconds, which bounds this. ### 2.5 The agent's client-side load balancer k3s agents seed their LB with `--server`, then sync the address list from the `kubernetes` EndpointSlice and keep the `--server` address as the default server [S k3s architecture, k3s loadbalancer/servers.go, tunnel.go `watchEndpointSlices`]. The brief's worry - endpoints are gateway-local addresses a guest cannot reach - does not apply: `--advertise-address` IS the address the guest's link binds on its own `lo`, and `--advertise-port` IS the port that link listens on (the market invariant, `steps.go:168`). Every slot must advertise the same pair, so the EndpointSlice reads one address that equals `--server` [M: `10.77.0.1:35443` with two servers]. With the front, advertise-port becomes the FRONT port (= the existing `k3s-secure` number) and the slot's listen port is never advertised; the experiment proves k3s honours a split advertise port in the EndpointSlice. `--disable-apiserver-lb` (experimental) exists on the agent [S k3s cli agent] and is not needed; do not add it - the LB's reconnect behaviour is what moves agents from blue to green. Endpoint removal on shutdown: the lease reconciler keys master leases by IP and calls `RemoveEndpoints` on graceful stop [S k8s lease.go]; both slots share the IP, so a graceful blue could blank the Endpoints until green's next reconcile. Not observed (blue's 0.1 s exit) [M]; the market network test must still assert the slice never empties [U]. ### 2.6 Kubelets, watches and NotReady across a swap - Watches: the front closes the client side when the backend closes; client-go reflectors re-list and re-watch through the front to green. Resource versions are kine row ids in the shared table, so a resumed watch is valid or gets 410 and re-lists. - Heartbeats: the kubelet renews `kube-node-lease/` (every 10 s by default) [S k8s leases/nodes]; the swap gap is the front switch plus one reconnect. - NotReady timing: kcm `--node-monitor-grace-period` default 50 s [S kcm]. The node lifecycle controller stamps a node it has not seen before with `probeTimestamp = now` [S node_lifecycle_controller.go "Creating timestamp entry for newly observed Node"]; a new kcm leader (green, or a cold-started cluster) therefore gives every node a fresh 50 s before it can mark it Unknown. NODE-LOSS then needs 90 s more of NotReady. A total control-plane outage costs a node only if its path does not recover within ~50 s after the control plane returns AND stays down 90 s more. - Agent tunnel (`egress-selector-mode cluster`): apiserver -> kubelet (logs, exec, attach, port-forward) rides the agent's websocket tunnel to the server it connected to. Between "front switched" and "agent reconnected to green", green has no tunnel for that node and exec/logs fail; when blue stops, every exec/attach stream through it ends. GitLab's kubernetes executor drives jobs through exec/attach [U: measure whether an in-flight job fails on a stream reset]. Consequence: swap when idle (zero nodes or no running runner pods) where possible; otherwise switch and stop blue immediately so tunnels move within one reconnect backoff [U: measure the backoff on the market network]. --------------------------------------------------------------------------------------------------- ## 3. kine - **Several kine instances on one database are the normal k3s HA shape** (every k3s server embeds one against the shared SQL datastore [S k3s HA]). Concurrency safety is in the schema: unique `(name, prev_revision)` makes a racing second create/update of the same revision fail like an etcd compare [S kine pgsql.go; M schema]. Revisions are the postgres sequence; concurrent writers leave temporary gaps, which the poll loop waits out and then fills with a gap record [S kine sqllog sql.go "MODREVISION GAP" / `Fill`]. Each kine's watchers see the other's writes by polling the table, so cross-slot watch latency is the poll interval, not zero. - **Compaction leader**: standalone kine's own compaction is off by default ("so that compact may be managed by the apiserver") [M `kine --help`]; kube-apiserver compacts every 5 min and coordinates across apiservers with a CAS on `compact_rev_key` ("CAS fail will not incur any error") [S k8s etcd3/compact.go]. No compaction leader to manage. - **Upgrades / schema**: on start kine runs `CREATE ... IF NOT EXISTS` and `schemaMigrations` (`ALTER TABLE kine ALTER COLUMN id ... BIGINT`, `ALTER ... name ... COLLATE "C"`) when needed [S kine pgsql.go; M "Database tables and indexes are up to date"]. An ALTER takes an ACCESS EXCLUSIVE lock, so a NEW kine starting beside an OLD one can stall the old one's writes for the migration. Rule: a kine bump is its own green slot, and its release notes are read for schema changes; a bump that migrates gets a market network test with old+new kine on one database first. kine has no downgrade path once it migrated. - Pairing: one kine per slot (kine@a with k3s@a). It keeps the kine version a property of the slot, lets a slot die without taking the other's store connection, and costs one leased port per slot. --------------------------------------------------------------------------------------------------- ## 4. The gateway-side link swap **Do not re-point dumbpipe for a k3s swap.** dumbpipe `listen-tcp` dials `--host` per accepted iroh connection [S dumbpipe main.rs `TcpStream::connect` in `listen_tcp`], so whatever holds the target port decides the backend per connection. A per-cluster L4 front on the target port makes the switch deterministic for every client at once: guests (via dumbpipe), the kcp mount (`status.URL`), the provider's admin client, CCM and ESO. Front design (decided by Claude, user may veto): - Binds `BindAddress:` - the port k3s binds today. Nothing upstream changes: dumbpipe `--host`, the guest's `apiserverPort` in every node secret, `status.URL`, kubeconfigs and the advertise port all keep their number. Existing guests need no rewrite. - Backend chosen per NEW connection from a state file or control socket; existing connections stay on their backend until it closes them (that is the drain). Pure TCP passthrough: TLS stays end to end with k3s (serving SANs already cover 127.0.0.1 and the advertise address). - Its own unit (`socialweb-c--front`), tiny, version-pinned, restarted almost never. Its listening socket is kept in systemd's fd store (`FDSTORE=1`, `FileDescriptorStoreMax`) so a front restart has no refused-connection window, only resets of open connections [U: user-manager fd store on the target systemd]. - Health: the front never fails over by itself; the reconciler switches it. (Auto-failover would hide a dead green.) **The iroh key stays put.** It lives in the cluster vault and is read in `Prepare` (`links.go:154-173`); any new dumbpipe process gets the same `IROH_SECRET`, so guest tickets stay valid (measured by the repo: `TestARealDumbpipeLinkCarriesTCPThroughItsPublishedTicketWhichARestartLeavesByteIdentical`). **Swapping dumbpipe itself** (dumbpipe upgrade, cutover): iroh-relay keeps one active and N inactive connections per endpoint id; a newer connection with the same id becomes active and the older ones get `SameEndpointIdConnected` and are promoted back if the active one leaves [S iroh-relay server/clients.rs]. So "start new dumbpipe with the same key, then stop the old one" moves relayed dials to the new process while open QUIC connections finish on the old. Unverified for our pin: dumbpipe v0.39.0's iroh version must already carry this relay behaviour, n0's public relays must run it, and discovery (signed address record published per process) is last-writer-wins [S iroh docs] [U all three: market network test]. The second process cannot bind the same UDP link port, so it takes the other slot's link lease (or a random port; the published ticket carries no address). Alternatives considered: (a) restart dumbpipe pointing at green - every client's connection drops at once and gateway-local clients still need re-pointing; (b) SO_REUSEPORT on the k3s port - k3s does not set it; (c) systemd-socket-proxyd - one fixed backend per process, a switch is a restart; (d) HAProxy or Envoy - a new asset with a runtime API for one backend flip; the Go front is ~150 lines and is socialweb code that never touches a VM (invariant holds). --------------------------------------------------------------------------------------------------- ## 5. Procedures Notation: slots `a`/`b` per cluster, each = `kine@` + `k3s@` units with their own leases; `front`, `bao`, `link-apiserver`, `link-openbao`, `ccm`, `eso`, `esowh` are single units; all under `socialweb-cluster-.slice`, after `socialweb-pg-.service` (A-x-ordering in plan-0006-0007-linux-exploration.md). A per-cluster record `/controlplane.json` holds {activeSlot, slots{a,b: version, kineVersion, ports, unit, invocationID}, front{port, backend}}. ### (a) k3s/kine version upgrade of one cluster (blue = active slot) 1. Preconditions: target k3s minor in {active, active+1}; target >= every guest minor and <= guest minor + 3; the cluster is Ready; `kubernetes` EndpointSlice = (ADV, front port). Prefer an idle moment: defer up to a bound while runner pods are Running (2.6). Check: provider reads the slice and the claim list. 2. `pg_dump` k3s + openbao databases to `/backups/` if the minor changes. Check: dump exit 0, size > 0. 3. Lease green ports (k3s pair, 3 controller ports, kine) if not held. Check: allocator bind probe. 4. Start `kine@green` (target kine) on the same DSN. Check: dial, log "tables and indexes are up to date", no lock wait > 2 s on the active kine. 5. Start `k3s@green` with identical cluster flags, same token file, same advertise pair, own data dir. Check: green CA hash == active CA hash; `/readyz` 200 on the green port with the admin cert; first kube-system Deployment listed (the existing `waitK3sControllers` gate) through the green port. 6. Switch the front backend to green; persist `controlplane.json` (activeSlot=green) BEFORE the switch is acknowledged. Check: a request through the front lands on green (green's audit log or `apiserver_request_total`). 7. Immediately SIGTERM `k3s@blue` (k3s exits in ~0.1 s); stop `kine@blue`. Check: front's blue connection count -> 0; EndpointSlice still (ADV, front port); every held market Node Ready (no transition); every claim unchanged. 8. Watch for 60 s (> node-monitor-grace): kcm/scheduler/ccm leases held by green (~16 s); agent tunnels re-established (an exec into one pod per node succeeds); ClusterLinks still Listening. 9. Keep blue's data dir and units' ExecStart (old version) for one rollback window; rollback = same procedure in reverse, only within the same minor. ### (b) cluster-group supervisor / core binary upgrade With members as units under a slice there is no resident supervisor: a core upgrade (A-x-upgrade step 3) leaves every unit running and the new core adopts by unit name + `controlplane.json` + InvocationID (Verdict machinery, Unknown retries, mismatch stops). Checks: every expected unit active, front backend == activeSlot, `/readyz` through the front, ClusterLinks Listening; no unit restarted (InvocationIDs unchanged). Members whose binary ships inside the core (CCM is our Go binary; the front) roll explicitly: CCM = stop/start (no election, ~seconds without Node reconciliation, harmless); front = restart with the fd store (open connections reset once, clients reconnect). If the decided single-unit group with a resident supervisor is kept instead: a supervisor upgrade restarts all members (cold restart of the cluster, (d) timings) unless it hands its children over, which Pdeathsig forbids. That is the main argument for 8.1's member-units refinement. ### (c) the one-time container -> host cutover Recommended: **guarded cold restart** (8.3). Steps: 1. Market network rehearsal (once): `internal/marketnetwork` with a market node joined through runComputeContract; stop the control-plane container; start the host core on a copy of its state; record time-to-Ready per cluster, time until the node's Lease renews, Ready transitions, exec success. 2. Ship NODE-LOSS rebase (M3-NODE-LOSS-CLOCK) and the front + slot layout in the host core FIRST, so the host start is the last cold start ever. 3. Prod: pick a window when the most clusters are at zero nodes (ACC-ZERO says idle clusters sit at 0). `docker stop` (no rm). Copy the volume to /var/lib/socialweb (uid move, linger first; existing M5-CUTOVER script). Start the user unit. 4. Checks as M5-CUTOVER done_when plus: per cluster, `/readyz` through the front, every held Node Ready with `lastTransitionTime` before the window or Ready again within 50 s, no claim deleted, no vm.delete sent. Rollback unchanged (stop unit, `docker start` on the untouched volume). The zero-downtime takeover the brief describes is feasible on `--network host` and is written out so its cost is visible (8.3 rejects it for a one-time event): 1. Root step: copy token, `postgres.pw`, vault seal file, vault TLS, audit/scheduler configs from the volume to the host state dir (the volume is root-owned; the gateway user cannot read it). 2. Freeze the container provider without killing its group: `kill -STOP ` (Pdeathsig fires only on death; a stopped provider reconciles nothing, so it neither respawns nor reaps). The entrypoint's `kill -0` liveness loop still passes. Check: no provider log lines. 3. Per cluster: host `kine@b` + `k3s@b` on new leases against the container's postgres (127.0.0.1:). Check as (a)5. 4. Kill the container's k3s (0.1 s) and bind the host front on the `k3s-secure` port in the same breath (front started earlier, retrying bind). The container's dumbpipe keeps dialing 127.0.0.1: and now reaches green. Check: (a)7. 5. Host `link-apiserver` and `link-openbao` with the same keys (read from the container's vault on loopback with the root token), new UDP ports; then kill the container's links (4. relay handover). Check: a guest-side dial (the market network) through the published ticket reaches the host process. 6. Vault: stop the container's bao, start the host bao on the same storage and port (no HA lock, so never two at once), unseal. Check `/v1/sys/health`, a node-secret read. 7. Postgres: add `host replication postgres 127.0.0.1/32 scram-sha-256` to the container pg_hba and reload; `pg_basebackup -R -X stream` into the host Postgres object's data dir on a new port; wait for `pg_stat_replication` replay lag 0; stop `kine@b`; `pg_promote()`; start `kine@b` with the new DSN (1-2 s write outage; kube-apiserver retries). Same for the vault DSN (bao restart). Preconditions: same PG major, same arch, compatible build; the openbao table's text keys use the database default collation (initdb ran with no `--locale`, `postgres.go:279-285`), so the host build's collation must match or `REINDEX` the openbao table after promotion; kine's `name` is `COLLATE "C"` and is safe. 8. Stop kcp/gateway/door in the container, copy the kcp root and door state, `docker stop`; start the host core; it adopts the units. kcp/door downtime remains (section 6). Cost: a cutover tool that drives all of the above per cluster + a rehearsal on the market network + failure handling for a half-moved cluster. Risk: the frozen provider, a half-moved cluster on any failure, a root-owned copy step mid-flight. ### (d) crash of a cluster group member - `k3s@active` dies: systemd `Restart=on-failure` restarts it in place (warm data dir: 1.7 s [M]); the front refuses nothing - it just fails dials until the port answers. Faster: the reconciler starts the standby slot and switches (2.2 s [M]) if the active slot fails twice. NotReady risk: none below ~50 s (2.6). - `kine@active` dies: restart in place; k3s retries its etcd client. - postgres dies: `Restart=on-failure` on the pg unit; kine reconnects; group untouched (Wants, not Requires). Measure bao's behaviour on a storage reconnect [U, already listed in the exploration]. - bao dies: restart + unseal by the reconciler; joined nodes do not need bao; new joins and ESO wait. - a link dies: restart with the same key (fix the "stated limit: a link that dies is not restarted", ARCHITECTURE section 5); nodes' dials fail for the restart time only. - front dies: restart from the fd store; all open apiserver connections reset once. - NODE-LOSS: rebase the loss clock to "path up" (8.2, M3-NODE-LOSS-CLOCK) so our own outage is never billed to a tenant's VM. ### (e) postgres minor and major upgrade - Minor: storage format never changes within a major; replace binaries and restart [S PG upgrading]. As a unit restart: fast shutdown + start, seconds; kine and bao reconnect. Checks: `SELECT 1`, `/readyz` through the front, no Ready transitions. - Major: three options [S PG upgrading, pg_upgrade]. (1) `pg_upgrade --link`: minutes of downtime, no extra space, no way back once the new cluster starts. (2) Logical replication into a new Postgres object of the new major, then a 1-2 s write freeze: stop kine slots, wait for catch-up, `setval('kine_id_seq', max(id))` on the new primary (sequences are not replicated by logical replication before PG 19 [U verify for the target major]), switch DSNs, start kine; same for openbao. (3) For small databases, dump/restore under the write freeze. Recommend (2) for zero downtime, (3) when the database is under ~100 MB [U measure prod sizes]. --------------------------------------------------------------------------------------------------- ## 6. kcp/door downtime: what stops and what keeps working | Stops while kcp + gateway + door restart | Keeps working | |---|---| | Tenant `kubectl` / CI runners through the kcp mount (users reach clusters through the mount; ARCHITECTURE section 7 "The mount requires status.phase == Ready", "The mint kubeconfig's own server: is loopback, not the mount") | every tenant apiserver on its own port | | `getKubeconfig` / exec-credential refresh via the gateway (cached tokens keep working until expiry) | joined nodes, their pods, kubelet heartbeats, meshd, CoreDNS | | door API + SPA, new RFPs, bidding, accept, the per-contract secrets servers (Bidding/Provisioning claims die today) | both cluster links, cluster vault, ESO sync, CCM (on its last instance snapshot) | | provider: scale decisions, NODE-LOSS evaluation, claim adoption, vault sync (if the provider is in-process with kcp per M3-SERVE-GATEWAY-KCP) | the tenant's own node lifecycle controller (it keeps recording real NotReady transitions) | NODE-LOSS does not need to pause or rebase across a kcp/door/provider restart: the clock is the tenant Node's `lastTransitionTime`, written by the tenant's kcm, which keeps running; a node that died during the restart is genuinely lost and the next provider pass releases it from the claim. The door only releases on claim deletion, from the receipt on the claim. Rebase IS needed when OUR side of the path was down (cluster cold start, link restart, front restart, cutover): count NotReady only from `max(lastTransitionTime, pathUpSince)`, where pathUpSince = when the provider last saw the cluster's apiserver answer through the front AND both ClusterLinks Listening (in memory; provider start counts as path-up for clusters it brings up). Cheap, and it removes the cutover's real exposure. Keeping tenant API access up across a core upgrade needs kcp out of the core process (exploration Q1 recommends out-of-process kcp in its own unit). Even then a kcp upgrade is a mount outage; kcp HA is out of scope here. --------------------------------------------------------------------------------------------------- ## 7. Durable ownership state Is `NodeClaim.status.contract` + market records enough? For Joining and Running nodes, yes: the claim names the cluster key (spec), the VM (vmUri/vmName), the contract (rfp/accept/receipt/submitEventRef), the workload grant, and its name is the tenant Node's name; the cluster vault holds the join material; the tenant datastore holds the Node. The provider and door re-derive everything from those today. Gaps: 1. **Bidding/Provisioning resume.** The secrets server's keypair and grant are per process (`guest-capability-secrets/mod.ts:50-75`). Add: a per-claim ingress key in OpenBao (the cluster's vault, KV `socialweb-door/claims//secrets-ingress`, CAS-0 like the link keys); the door re-creates the ingress from it on restart, so the URL in the guest's user_data still resolves; the authorizer grant is rebuilt from `status.grant`; the bundle is rebuilt (ticket, CA, claim.json are deterministic). Claim fields: `status.secrets {ingressDid, keyRef, url, servedAt}`. 2. **The accept-before-status edge** (ARCHITECTURE section 9: "an accept written just before a crash ... is a VM no claim names"). Add a write-ahead `status.contract.acceptPending {bidUri}` before the accept record is written; a restarted door looks for an accept naming that RFP in the requester's repo and completes or releases. This removes most of M6-GATEWAY-REPOPULATE's record scan. 3. **Control-plane runtime record** (host-local, read before kcp is up): `/controlplane.json` (section 5) plus unit names; mirrored to `Cluster.status.controlPlane` for visibility only. Adoption reads units + this file; never kcp. 4. Schema cost: `APIResourceSchema.spec` is immutable, so new NodeClaim status fields are a schema revision + export re-point (versions.json `our-crds`); batch 1 and 2 into one revision. Proposed ownership model (decided by Claude, user may veto): - **Owner of a VM = exactly one NodeClaim.** Claim name = tenant Node name = guest vmName. The claim is written before the RFP (Bidding) and carries every reference needed to release the VM without memory. The market records are evidence; the claim is the index. - **Owner of a control plane = the host**: units + `controlplane.json` + leases.json; the Cluster CR is desired state and status mirror. - **Join material = the cluster vault**, keyed by claim name; link keys never regenerated. - **Liveness = the tenant Node**, judged with the rebased loss clock. - Restart = adopt, never reap: door adopts Joining/Running and resumes Bidding/Provisioning from gap 1; provider adopts claims and units; anything with a receipt and no claim (gap 2 not yet closed) goes through M6-GATEWAY-REPOPULATE's Orphaned rule. --------------------------------------------------------------------------------------------------- ## 8. Recommendation ### 8.1 Design 1. Per cluster: `socialweb-cluster-.slice` with member units (front, kine@a|b, k3s@a|b, bao, link-apiserver, link-openbao, ccm, eso, esowh), ordered after `socialweb-pg-.service`. Refines the decided "cluster group is its own unit": the group is still one systemd object (the slice; stop = stop the slice) but each member has its own lifetime, so blue/green needs no resident supervisor and a core upgrade touches nothing. Pdeathsig is no longer needed inside the group (units own lifetime); plan 0005 M6-LIMITS' guarantee becomes "every member is a unit of the cluster's slice". 2. Front on the `k3s-secure` port; slots on new leases; `AssertMarketAdvertiseInvariant` rewritten: advertise port == front port, advertise address identical across slots, slot listen ports never advertised. 3. Scheduler `leaderElect: true` with a short lease; kcm/ccm defaults kept. 4. Swap policy: idle-first (0 nodes or no running runner pods, bounded deferral), else blue/green with switch-then-stop. Minor bumps one at a time with a pg_dump first. 5. NODE-LOSS clock rebased to path-up. 6. Door: per-claim ingress key in OpenBao + `status.secrets`; write-ahead accept. ### 8.2 Plan items (new / changed) ```yaml # plan 0006 - id: M3-CLUSTER-UNITS # replaces M3-CLUSTER-SCOPES (rename already proposed in the exploration) title: A cluster is a slice of member units; a core upgrade restarts none of them depends_on: [M2-HOST-SCOPES, M2-POSTGRES] decision: >- Decided by Claude, user may veto (Q-H1): each cluster is socialweb-cluster-.slice holding one transient service per member (front, kine@, k3s@, bao, link-apiserver, link-openbao, ccm, eso, esowh), After/Wants socialweb-pg-.service. The reconcile loop is the supervisor (ARCHITECTURE section 5 already says so); adoption = unit name + InvocationID + /controlplane.json. Pdeathsig inside the group is retired; M6-LIMITS is restated as "every member is a unit of its cluster's slice". test_first: >- Behind livegate, stand-ins only: TestACoreRestartLeavesEveryClusterUnitWithItsInvocationID (systemctl --user restart of the gateway unit + a binary swap; /readyz through the front polled every 200 ms, no failure). On the market network (node joined via runComputeContract): TestAGatewayUpgradeKeepsAMarketNodeReady (no Ready transition, no claim write, no vm.delete). - id: M3-APISERVER-FRONT title: A per-cluster L4 front holds the apiserver port; k3s moves behind it depends_on: [M3-CLUSTER-UNITS] decision: >- Decided by Claude, user may veto: the front binds the k3s-secure lease port (unchanged number, so no node secret, ticket, status.URL or kubeconfig changes); k3s slots take new k3s-slot-a/b leases (width 2) and per-slot controller and kine leases; --advertise-port = front port for every slot; backend switched per new connection by the reconciler only; listening socket kept in the systemd fd store. AssertMarketAdvertiseInvariant is rewritten to the front. test_first: >- TestTheFrontSwitchFailsNoPoll (two k3s --disable-agent stand-ins on one kine/postgres, 200 ms authenticated /readyz through the front across switch + SIGTERM: 0 failures - the measurement in control-plane-handover.md 2.1 as a test); TestEverySlotAdvertisesTheFrontPair (EndpointSlice == (ADV, front port) with one, two and the remaining slot). Red first. - id: M3-CP-SLOTS title: Blue/green k3s and kine slots per cluster depends_on: [M3-APISERVER-FRONT] decision: >- Decided by Claude, user may veto: procedure (a) of control-plane-handover.md 5; scheduler leaderElect true with a short lease; idle-first swap policy with a bounded deferral; minor bumps one at a time with a pg_dump first; rollback within a minor only. The k3s hostname-identity lease collision is bounded by switch-then-stop. test_first: >- Stand-ins: TestAGreenSlotSharesCAAndTokenWithBlue, TestTheSchedulerIsNotDoubledDuringOverlap. Market network (node via runComputeContract): TestABlueGreenSwapKeepsAMarketNodeReadyAndExecWorksAfter (no NotReady, no claim change; exec into the node's pod succeeds within N s of the swap - N is the measured agent tunnel backoff). - id: M3-LINK-HANDOVER title: A dumbpipe link is replaced with the same key and no dial lost depends_on: [M3-CLUSTER-UNITS] decision: >- Measure, then decide: start the new listener with the same IROH_SECRET on another UDP port, wait for its ticket, stop the old one. Fixes the stated limit that a dead link is never restarted. test_first: >- Market network: TestALinkReplacedWithTheSameKeyKeepsTheNodeReady (guest's connect-tcp dials across the swap; relay promotion and discovery timing recorded for the pinned dumbpipe). - id: M5-CUTOVER # amended decision_add: >- Recommended (8.3): guarded cold restart, rehearsed once on the market network, after M3-CP-SLOTS and M3-NODE-LOSS-CLOCK ship in the host core; window chosen when most clusters are at zero nodes. The host-side takeover of a frozen container is documented, not built. depends_on_add: [M3-APISERVER-FRONT, 0007:M3-NODE-LOSS-CLOCK] test_first_add: >- Market network: TestAContainerToHostCutoverKeepsAMarketNodeOwned (control-plane container stopped, host core started on the copied state: claim adopted, Node Ready within 50 s, no vm.delete). - id: M2-POSTGRES # amended decision_add: >- Minor upgrade = unit restart. Major upgrade = logical replication into a new Postgres object plus a write freeze with the kine sequence set on the new primary; dump/restore below a size bound. The host build's default collation must equal the container's or the openbao table is reindexed. # plan 0007 - id: M3-NODE-LOSS-CLOCK title: Our own outage never counts against a tenant's node depends_on: [] decision: >- Decided by Claude, user may veto: NotReady time counts from max(lastTransitionTime, pathUpSince); pathUpSince = when the provider last saw the cluster's apiserver through the front and both ClusterLinks Listening (provider start counts for clusters it brings up). test_first: >- cmd/socialweb-clusterprovider: TestANodeNotReadyDuringOurLinkOutageIsKeptFor90sAfterTheLinkReturns, TestANodeNotReadyBeforeAProviderRestartIsStillReleasedOnTime. Red first. - id: M6-CLAIM-RESUME # split out of M6-GATEWAY-REPOPULATE title: A restarted door resumes Bidding and Provisioning claims depends_on: [M4-BIDDER-VAULT] decision: >- Per-claim secrets-ingress key in the cluster OpenBao (CAS-0), status.secrets {ingressDid, keyRef, url, servedAt}, write-ahead status.contract.acceptPending; one NodeClaim schema revision. test_first: >- test/node_market_test.ts: 'a door restarted during Provisioning re-serves the same secrets URL and the claim reaches Joining', 'a door restarted between accept intent and accept record completes or releases exactly once'; market network: TestADoorRestartMidProvisioningStillJoinsTheNode (node born via runComputeContract). Red first. - id: M6-GATEWAY-REPOPULATE # amended depends_on_add: [M6-CLAIM-RESUME, 0006:M3-CLUSTER-UNITS] decision_change: the record scan only covers claims with acceptPending or no claim at all. ``` All live tests stay on the RFP spine: a node exists only because the market network's requester ran `runComputeContract` (RFP -> bid -> accept -> cloud-init) against a local bidder; control-plane stand-in tests start k3s/kine/postgres processes only, never a guest. ### 8.3 Cutover choice | Option | Cost | Risk | Tenant effect | |---|---|---|---| | Drain first (release every market node) | low code | none to ownership | running jobs killed; nodes re-rented after | | Raise grace for the window | trivial | stale VMs live longer if really lost | none if path recovers | | Accept loss (plain cold restart) | none | depends on path recovery < ~140 s | API outage for the window; nodes probably Ready | | **Guarded cold restart** (rebased clock + market network rehearsal + idle window) | M3-NODE-LOSS-CLOCK (small) + one market network run | low: a node is released only if it was really unreachable 90 s after our path was back | API outage for the window (tens of seconds per cluster + kcp/door); exec streams reset | | Blue/green takeover of a live container | a one-shot cutover tool, freeze trick, per-cluster rollback states, a rehearsal | medium-high: half-moved clusters, frozen provider, root copy mid-flight | apiservers up; kcp/door still restart, so the mount is down anyway | **Recommend the guarded cold restart.** The mount outage (kcp/door restart) cannot be avoided in the cutover in any option, so the extra zero-downtime engineering buys only "apiserver up for in-cluster clients", which the kubelets do not need (they tolerate ~50 s + 90 s). Put the zero-downtime effort where it recurs: front + slots make every LATER k3s/kine/core upgrade zero-downtime. ### 8.4 Questions for the user (recommended answer first) - **Q-H1** Cluster group as a slice of member units (front, kine@slot, k3s@slot, bao, links, ccm, eso) instead of one unit with a resident supervisor? **Yes** - blue/green and supervisor upgrades need members with independent lifetimes; a supervisor-held group cold-restarts on its own upgrade. - **Q-H2** Put a per-cluster L4 front on the existing apiserver port and move k3s behind it? **Yes** - nothing on any guest changes, and it measured 0 failed requests across a swap. - **Q-H3** Turn kube-scheduler leader election back on with a ~4 s lease (first pod after scale from zero may wait up to ~4 s instead of 0)? **Yes** - two slots otherwise schedule concurrently. - **Q-H4** Swap policy: idle-first with a bounded deferral (default 30 min), then blue/green even with running jobs? **Yes** - exec/attach streams reset on any swap. - **Q-H5** Cutover: guarded cold restart rather than a live takeover of the container? **Yes** (8.3). - **Q-H6** NODE-LOSS counts only time when our own path was up (rebased clock)? **Yes**. - **Q-H7** Door: per-claim secrets-ingress key in the cluster OpenBao so Provisioning claims resume (one NodeClaim schema revision with status.secrets + acceptPending)? **Yes**. - **Q-H8** k3s minor upgrades: one minor at a time, pg_dump before, rollback only within a minor? **Yes**. - **Q-H9** Take kcp out of the core process (its own unit) so a core upgrade is not a tenant API outage through the mount? **Yes, per exploration Q1** - otherwise every core upgrade is a mount outage however good the k3s handover is. --------------------------------------------------------------------------------------------------- ## Sources - k3s HA with an external DB: https://docs.k3s.io/datastore/ha - k3s token and bootstrap-data encryption: https://docs.k3s.io/cli/token - k3s manual upgrades ("server nodes first one at a time"): https://docs.k3s.io/upgrades/manual - k3s architecture, agent load-balancer: https://docs.k3s.io/architecture - k3s agent CLI (`--disable-apiserver-lb`, `--lb-server-port`): https://docs.k3s.io/cli/agent - k3s agent LB source: https://github.com/k3s-io/k3s/blob/master/pkg/agent/loadbalancer/servers.go - k3s agent tunnel / EndpointSlice watch: https://github.com/k3s-io/k3s/blob/master/pkg/agent/tunnel/tunnel.go - k3s server controllers + leader election: https://github.com/k3s-io/k3s/blob/master/pkg/server/server.go - wrangler leader election (hostname identity, 45 s lease): https://github.com/rancher/wrangler/blob/main/pkg/leader/leader.go - Kubernetes version skew policy: https://kubernetes.io/releases/version-skew-policy/ - kube-controller-manager flags (node-monitor-grace-period 50s, lease 15s): https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/ - Node lifecycle controller (fresh probeTimestamp for a newly observed node): https://github.com/kubernetes/kubernetes/blob/master/pkg/controller/nodelifecycle/node_lifecycle_controller.go - apiserver compactor CAS on compact_rev_key: https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/pkg/storage/etcd3/compact.go - Lease endpoint reconciler (master leases by IP, RemoveEndpoints): https://github.com/kubernetes/kubernetes/blob/master/pkg/controlplane/reconcilers/lease.go - Leases (node heartbeats, apiserver identity): https://kubernetes.io/docs/concepts/architecture/leases/ - Nodes (heartbeats, Lease): https://kubernetes.io/docs/concepts/architecture/nodes/ - kine README: https://github.com/k3s-io/kine/blob/master/README.md - kine postgres driver (schema, migrations): https://github.com/k3s-io/kine/blob/master/pkg/drivers/pgsql/pgsql.go - kine SQL log (gap fill, poll): https://github.com/k3s-io/kine/blob/master/pkg/logstructured/sqllog/sql.go - iroh-relay duplicate endpoint ids (active/inactive, SameEndpointIdConnected): https://github.com/n0-computer/iroh/blob/main/iroh-relay/src/server/clients.rs - iroh address lookup (signed records per endpoint): https://docs.iroh.computer/connecting/dns-address-lookup - iroh concepts: https://docs.iroh.computer/concepts - dumbpipe (IROH_SECRET, listen-tcp dials --host per connection): https://github.com/n0-computer/dumbpipe/blob/main/src/main.rs - PostgreSQL upgrading (minor = replace binaries; logical replication "several seconds"): https://www.postgresql.org/docs/current/upgrading.html - pg_upgrade: https://www.postgresql.org/docs/current/pgupgrade.html - pg_basebackup: https://www.postgresql.org/docs/current/app-pgbasebackup.html