graph-os Self-Hosting Cutover — Design (EXECUTED 2026-07-26)¶
✅ EXECUTED 2026-07-26 against
platform/graph-os+platform/epistemic-graph. Result: one self-containedgraph-ospod on<ENGINE_NODE>(engine native-sidecar + graph-os,2/2 Ready), engine loaded 87 graphs (matches pre-cutover baseline), graph-os connectedresolved_mode=remote endpoint_count=1 reachable_count=1 breaker=0 latency=0.4msover the in-pod unix socket, messaging up (mattermost+telegram). Oldepistemic-graphDeployment kept scaled to 0 (not removed) for the rollback/soak window. One deviation from the spec below: the sidecar binds--tcp-addr 0.0.0.0:9100(not127.0.0.1:9100) because atcpSocketstartupProbe dials the POD IP, not loopback — a loopback-only bind makes the probe "connection refused" forever. Same TLS+HMAC posture as the pre-cutover engine (which also bound 0.0.0.0), so no security regression. FOLLOW-UP to restore true loopback-only: switch the three probes to anexecpythonsocket.create_connection(('127.0.0.1',9100))check, then revert the bind to127.0.0.1. Live manifest persisted toservices/graph-os/k8s/graph-os.deployment.yaml.Scope: the
unified-in-processengine topology (the self-contained default —reports/unified-binary-program.md, genesis skill referencegraphos-genesis/references/engine-topology-and-hyperscaling.md§"Shape 1"). This is the OPPOSITE shape fromgraphos-horizontal-scaling.md(out-of-process-shared, N graph-os pods against one external engine) — do not run both cutovers on the same Deployment. This document is design only; the orchestrator runs it live, on its own schedule, against the realplatform/epistemic-graph+platform/graph-os. Nothing in this document has been applied.Placeholders used below (this is a public page — the literal values are homelab-specific and deliberately not repeated here; resolve them with the cited
kubectl getcommands, exactly as ingraphos-horizontal-scaling.md):<ENGINE_NODE>/<ENGINE_NODE_IP>— the node the redb hostPath pins the engine to, and its cluster IP.<ENGINE_HOST_HOME>— the operator home directory backing the redb/TLS/config hostPath mounts.<WORKSPACE_NFS_SERVER>/<WORKSPACE_NFS_EXPORT>— the existing NFS exportau-src/eg-wheelalready use.<REGISTRY>— wherever the unified image is published.
Why this is possible now, and why it wasn't before¶
docker/graphos-unified.Dockerfile (W-C) now bakes the engine binary
(epistemic-graph-server, at /usr/local/bin/) into the same image as
graph-os/kg_server — confirmed live: platform/graph-os already runs
<REGISTRY>/graph-os-unified@sha256:… today. Before this image existed, co-locating
engine + graph-os in one pod required two different container images (see
services/epistemic-graph/k8s/bundled-core-pod.yaml Variant A: an ubuntu:26.04 engine
sidecar with a hostPath-mounted binary, alongside a separate <REGISTRY>/agent-utilities
graph-os container) — the genesis reference already names that shape "a valid stepping
stone toward the tighter PyO3 embedding, not a competing target." This document is that
stepping stone made concrete against the current unified image and the real 87-graph
production store, superseding the need for two images: both containers below use the
SAME unified image, differing only in command.
Two mechanisms, only one designed here:
- (a) INTERIM (this document). The engine runs as a second process in the same
pod as graph-os, using the engine's OWN existing production config — a k8s native
sidecar (
initContainersentry withrestartPolicy: Always, k8s ≥1.29; this cluster is 1.35), not a single shell script that backgrounds a process. graph-os connects to it over a local socket exactly as it connects to today's separate Deployment — nothing inkg_serverchanges. - (b) FINAL — W-A's in-process PyO3 engine.
epistemic_graph.engineembedded via PyO3 in the SAME process askg_server(no second process, no socket at all). Perreports/unified-binary-program.md, this is NOT STARTED (the largest workstream). Nothing below depends on it landing; (a) is a complete, shippable end state on its own and is not "temporary scaffolding" for (b) — it removes the separate Deployment today, and (b) can replace this pod's sidecar with an in-process call later without another data migration (same store, same identity, only the transport inside the pod changes).
The identity trap this design exists to avoid¶
Do not remove the separate epistemic-graph Deployment and simply let graph-os's
existing EngineResolver autostart a local engine against the same --persist-dir.
It will start, but it will not can serve the 87-graph store, and it will fail in
one of two ways depending on whether graph-os's own process already happens to carry an
EPISTEMIC_GRAPH_SIGNER_KEYS_JSON (it does not, today):
- Read
agent_utilities/knowledge_graph/core/graph_compute.py(_autostart_engine, ~L1656–1711): when it spawns a child engine, it takes the current request's own checked actor (bootstrap_session.engine_verified_context()— a per-caller Keycloak identity, not a static service account) and either (i) if the parent process already carries anEPISTEMIC_GRAPH_SIGNER_KEYS_JSON, requires that exact actor to already be a key in it —raise RuntimeError("verified process identity is absent from the engine signer registry")if not — or (ii) if no registry is configured at all, mints a brand-new throwaway signer key for just that one actor and hands it to the fresh child. - Neither case is what the 87-graph store's
rbac.redbwas actually provisioned with. Perreports/HANDOFF-2026-07-22.md§7, the store's RBAC was bootstrapped forhomelab-systemand graph-os's own service-account subject — a per-request end-user identity (case i) or a brand-new ad hoc one (case ii) matches neither, and the engine answersACCESS_DENIED: a provisioned identity/RBAC policy is required(or, against a genuinely empty store, will instead treat it as a fresh bootstrap — also wrong, since this store is not fresh). - Autostart is designed for a zero-config local/tiny engine, not for attaching to an
already-governed production store.
resolve_engine(knowledge_graph/core/engine_resolver.py) only takes the autostart leg whenGRAPH_SERVICE_ENDPOINTSis absent. The fix is simply to never let that happen: always setGRAPH_SERVICE_ENDPOINTSexplicitly, pointing at the sidecar engine. With it set, the resolver's precedence isremote— "every configured topology is a hard contract: connect to it, never auto-spawn a local stand-in" — the exact same code path graph-os already uses to reach today's separate engine, just now over a socket inside its own pod instead of TLS to a sibling pod. The sidecar engine is started directly (its own binary, its own CLI flags, its own env) — never through_autostart_engine.
This is the whole of "sidesteps the autostart identity wall": not a workaround, but not exercising the autostart code path at all, by construction.
The redb single-writer-lock handoff¶
The engine enforces an OS-level single-writer lock on --persist-dir
(src/main.rs, persist_lock::acquire, held for the whole process lifetime; a second
engine pointed at the same directory refuses to start while the lock is held). There is
no hot handoff — the old engine must fully release the lock before the new one can
take it:
1. Freeze writes / note the current graph count for a post-cutover sanity check.
2. Snapshot the redb store (see "Pre-flight" below) — the ~2-3 min window in step 3
is exactly the kind of change the workspace's own migration discipline requires
rehearsing against a COPY first (proven practice: reports/HANDOFF-2026-07-22.md
caught two production-breaking regressions this way before they hit the real
9.9G store).
3. Scale platform/epistemic-graph to 0 (NOT delete — see Rollback).
-> lock released. KG is now DOWN for every client (graph-os has no other engine).
4. Roll platform/graph-os to the new pod spec (below). The engine sidecar acquires
the lock, loads the redb catalog (4 shards, ~45s-2min historically —
startupProbe budgets to it), and the sidecar's startupProbe passes.
-> kubelet was blocking graph-os's own container start until this passed;
it now starts, connects (mode="remote", see above), and serves.
Total KG-down window: stop (near-instant) + new-engine-load — the same ballpark
as today's engine `Recreate` restarts (an eg cutover on 2026-07-22 measured
~2.5 min end-to-end for a comparable stop/reload).
⚠ **This estimate no longer holds at current store size (D-CDX-65, open).**
A governed rollout on 2026-08-02 (main `3329180d`) measured the old pod
terminated at `11:10:48Z`, the engine logging "Acquired the configured
single-writer persistence lock" at `11:11:38Z`, then **no further progress
for 5m26s** (only `redb: catalog-loaded 87 graph(s) from 4 shard(s) —
lazy startup, no node/edge data read` at `11:17:04Z`) before the sidecar's
startupProbe passed and serving containers started at `11:17:05-06Z` — a
measured **~6m17s** end-to-end KG-down window against an ~11GB persist-dir
(4 shards: 532MB/5.0GB/4.0GB/1.2GB), not the ~45s-2min this section
originally scoped for. The startup budget (`failureThreshold: 200,
periodSeconds: 3` = 600s) already accounts for this being slow, but gives
operators and connecting clients no progress signal during the wait —
only connection-refused. D-CDX-65 (open) owns instrumenting per-shard
phase/bytes/records/duration so this is a visible, honest progress signal
instead of a silent multi-minute gap, and profiling whether the "lazy,
no node/edge data read" catalog load can be made to actually cost close
to nothing, as its own log line claims it should.
5. Verify (checklist below), THEN delete the old epistemic-graph/Service, or leave
them scaled to 0 for a rollback window before deleting.
Out of scope / do not touch: platform/epistemic-kvcache (image
registry.arpa/epistemic-graph:kvcache, runs on the cluster's GPU node) shares the
engine codebase but is an unrelated workload (vLLM KV-cache layering, not KG
storage) on a different node — this cutover does not affect it.
Check-before-cutover item (do not assume): the live epistemic-graph container
declares containerPort: 9130 name: kvcache alongside 9100, and
graph-os-env's EPISTEMIC_GRAPH_KVCACHE_URL=http://<ENGINE_NODE_IP>:9130 points at it — but
the container's actual CLI args (kubectl get deploy epistemic-graph -o yaml) show no
corresponding --kvcache-addr-style flag today. Confirm whether anything is really
listening on 9130 on the CURRENT engine before cutover (e.g. kubectl exec a connect
test against it, or check the engine's own startup log for a "kvcache" listener line —
both read-only). If something is, add the equivalent flag to the sidecar's args below;
if nothing is, the port declaration is vestigial and needs no equivalent.
Pre-flight (mandatory — do not skip)¶
Mirrors the two techniques that already prevented outages in this exact system
(reports/HANDOFF-2026-07-22.md §"pre-flight techniques"):
- Copy the redb store (
<ENGINE_HOST_HOME>/epistemic-graph/graph_snapshots, hostPath on) to a scratch path. Run the new pod spec's engine sidecar command standalone (a throwaway pod, --persist-dirpointed at the COPY, same TLS/signer/auth env) against the copy first. Confirm: catalog loads, graph count matches the live store,EPISTEMIC_GRAPH_REQUIRE_OIDC=false+ the shared HMAC secret authenticate a test request. - Import-test inside the running pod without restarting it — not applicable here
the same way (this is a topology change, not a code drop), but the equivalent is:
deploy the new pod spec to a scratch Deployment name (e.g.
graph-os-selfhost-rehearsal) pointed at the COPY from step 1, check it reachesReady, THEN do the real cutover against production. This costs one extra pod but converts an irreversible-feeling cutover into a rehearsed one. - Confirm the kvcache item above.
- Confirm nothing besides
graph-oscurrently dials theepistemic-graphService directly (§"redb single-writer-lock handoff" already establishedgraph-os-hostandagent-utilities-messagingare both scaled to0/0fleet-wide —kubectl get deploy --all-namespaces— i.e. graph-os is already the sole live consumer; messaging/ host-daemon are bundled INTO the unified graph-os image perreports/unified-binary-program.md's status ledger, not separate processes anymore). Re-check this is still true immediately before cutover, since it is the basis for binding the sidecar's TCP listener to loopback-only in the spec below.
The pod spec¶
Both containers reference the same unified image
(<REGISTRY>/graph-os-unified:<tag> — the validated latest/w-c-validation, not a
:langfuse-style follow-up validation tag unless that work has separately been promoted).
As of D-IMG-2/D-CDX-18/D-EIMG-5 (2026-08-02), that tag is produced by the
homelab/containers/images/graph-os-unified GitLab CI pipeline (see docker/README.md
§5), not a hand-applied Kaniko Job. Deltas from today's live graph-os Deployment are
called out inline; everything not mentioned (au-config/au-src/eg-wheel mounts,
graph-os-env/graph-os-secrets, probes, resources, the existing graph-os
Service/Ingress) is unchanged.
apiVersion: apps/v1
kind: Deployment
metadata:
name: graph-os
namespace: platform
labels: {app: graph-os, tier: kg-mcp, topology: unified-in-process}
spec:
replicas: 1
strategy:
type: Recreate # HARD requirement, not style: a second engine can never hold the
# redb lock while the first is still shutting down — a rolling
# update (maxSurge>=1) would start a NEW pod's sidecar against the
# SAME --persist-dir while the OLD pod's sidecar still holds the
# lock, guaranteeing the new one fails to start.
selector:
matchLabels: {app: graph-os}
template:
metadata:
labels: {app: graph-os, tier: kg-mcp, topology: unified-in-process}
spec:
# No hostNetwork. Both containers share ONE pod network namespace by
# construction — the reason today's SEPARATE Deployments need hostNetwork
# (reaching a sibling POD's loopback) does not apply to sibling CONTAINERS in the
# same pod. Only the redb hostPath still forces node pinning.
nodeSelector: {kubernetes.io/hostname: <ENGINE_NODE>}
terminationGracePeriodSeconds: 60 # covers the engine's clean shutdown + graph-os preStop
# ── NATIVE SIDECAR (k8s >=1.29; this cluster is 1.35) ─────────────────────────
# kubelet blocks the `containers:` below from starting until this startupProbe
# passes — no polling wait-init script, no sleep, no crash-loop-and-retry on
# graph-os. Same mechanism bundled-core-pod.yaml's Variant A already uses and
# already documents as the reason to prefer this over "a manual wait-for-socket
# loop" — reused here verbatim, just on the ONE unified image instead of two.
initContainers:
- name: engine
restartPolicy: Always
image: <REGISTRY>/graph-os-unified:<tag> # SAME image as graph-os below
imagePullPolicy: Always
command: ["sh", "-c"]
args:
- ulimit -Sn 524288; exec /usr/local/bin/epistemic-graph-server
--socket-path /run/epistemic-graph/epistemic-graph.sock
--tcp-addr 127.0.0.1:9100
--tcp-tls-cert /etc/eg-tls/tls.crt --tcp-tls-key /etc/eg-tls/tls.key
--persist-dir /data/graph_snapshots
# loopback-only TCP (was 0.0.0.0): nothing outside this pod needs to reach
# the engine directly anymore (see pre-flight #4) — the TCP listener exists
# ONLY so this container's own probes below can use a cheap tcpSocket check;
# the real traffic from graph-os uses the unix socket. Kept on TLS anyway
# (harmless, and the cert material is already provisioned) rather than
# dropping it for loopback, so nothing about the engine's own security
# posture regresses relative to today.
envFrom:
- secretRef: {name: epistemic-graph-secrets} # UNCHANGED — same 6 keys
env:
- {name: EPISTEMIC_GRAPH_REQUIRE_OIDC, value: "false"} # matches today
- {name: RUST_LOG, value: info}
ports:
- {containerPort: 9100, name: engine, protocol: TCP}
startupProbe:
tcpSocket: {port: 9100}
periodSeconds: 3
failureThreshold: 40 # ~120s ceiling, matches today's engine Deployment
timeoutSeconds: 1
readinessProbe:
tcpSocket: {port: 9100}
initialDelaySeconds: 5
periodSeconds: 15
failureThreshold: 3
timeoutSeconds: 1
livenessProbe:
tcpSocket: {port: 9100}
initialDelaySeconds: 5
periodSeconds: 20
failureThreshold: 3
timeoutSeconds: 1
resources:
requests: {cpu: "1", memory: 4Gi}
limits: {cpu: "6", memory: 24Gi} # unchanged from today's engine Deployment
volumeMounts:
- {mountPath: /data/graph_snapshots, name: redb}
- {mountPath: /run/epistemic-graph, name: engine-socket}
- {mountPath: /etc/eg-tls, name: eg-tls, readOnly: true}
containers:
- name: graph-os
image: <REGISTRY>/graph-os-unified:<tag> # SAME image as the sidecar above
imagePullPolicy: Always
command: ["python3", "-m", "agent_utilities.mcp.kg_server"] # unchanged
env:
# THE line that makes this connect, not autostart (see identity-trap section
# above) — overrides whatever the durable config.json otherwise resolves.
- {name: GRAPH_SERVICE_ENDPOINTS, value: "unix:///run/epistemic-graph/epistemic-graph.sock"}
- {name: EUNOMIA_TYPE, value: none}
- {name: KG_DAEMON_ROLE, value: client}
- {name: KG_LOOP, value: "0"} # client serves; an explicit host owns loops
- {name: KG_FUSEKI_ENDPOINT, value: "http://fuseki.apps.svc.cluster.local:80"}
- {name: PYTHONPATH, value: /au}
envFrom:
- configMapRef: {name: graph-os-env} # ENGINE_CA_BUNDLE/ENGINE_TLS_SERVER_NAME
# become IRRELEVANT once GRAPH_SERVICE_ENDPOINTS
# above is unix:// (engine_transport.py only
# consults them for a tls:// endpoint) — left
# in place rather than pruned, so falling back
# to the separate-engine shape (rollback) needs
# no ConfigMap edit, only the env override above removed.
- secretRef: {name: graph-os-secrets}
ports:
- {containerPort: 8000, name: http, protocol: TCP}
lifecycle:
preStop:
exec: {command: ["/bin/sh", "-c", "sleep 15"]}
readinessProbe: # unchanged from today's live graph-os Deployment
httpGet: {path: /health/ready, port: http, httpHeaders: [{name: Host, value: graph-os.arpa}]}
initialDelaySeconds: 20
periodSeconds: 10
failureThreshold: 12
timeoutSeconds: 5
livenessProbe:
httpGet: {path: /health, port: http, httpHeaders: [{name: Host, value: graph-os.arpa}]}
initialDelaySeconds: 10
periodSeconds: 20
timeoutSeconds: 5
startupProbe:
httpGet: {path: /health, port: http, httpHeaders: [{name: Host, value: graph-os.arpa}]}
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 60
resources:
requests: {cpu: 500m, memory: 1Gi}
limits: {cpu: "4", memory: 6Gi}
volumeMounts:
# UNCHANGED from today: au-config, local-data, homelab-ca, eg-wheel, au-src.
- {mountPath: /root/.config/agent-utilities, name: au-config, readOnly: true}
- {mountPath: /root/.local/share/agent-utilities, name: local-data}
- {mountPath: /etc/ssl/homelab, name: homelab-ca, readOnly: true}
- {mountPath: /eg-wheel, name: eg-wheel, readOnly: true}
- {mountPath: /au, name: au-src, readOnly: true}
# NEW: the socket the sidecar writes to.
- {mountPath: /run/epistemic-graph, name: engine-socket}
volumes:
# NEW — was a hostPath (`/run/epistemic-graph` on <ENGINE_NODE>) shared via co-scheduling;
# now an in-pod emptyDir, the SAME simplification bundled-core-pod.yaml already
# made for the identical reason (makes the coupling explicit: these two
# containers share a socket because they are IN one pod, not because of an
# incidental host mount).
- {name: engine-socket, emptyDir: {}}
# UNCHANGED — must stay hostPath, never NFS (redb needs real POSIX advisory
# locks; see MEMORY.md k8s-swarm-cutover "DBs never NFS").
- name: redb
hostPath: {path: <ENGINE_HOST_HOME>/epistemic-graph/graph_snapshots, type: Directory}
# RECOMMENDED small deviation from today: mount the k8s Secret directly instead
# of the hostPath copy (`<ENGINE_HOST_HOME>/eg-tls`) the live Deployment currently
# uses — one less manually-synced copy of the same material. Revert to the
# hostPath (unchanged from today) if the operator prefers zero deviation here.
- name: eg-tls
secret: {secretName: epistemic-graph-tls}
# UNCHANGED from today's graph-os Deployment:
- {name: au-config, hostPath: {path: <ENGINE_HOST_HOME>/.config/agent-utilities-next, type: Directory}}
- {name: local-data, emptyDir: {}}
- {name: homelab-ca, configMap: {name: homelab-ca-bundle, defaultMode: 420}}
- {name: eg-wheel, nfs: {server: <WORKSPACE_NFS_SERVER>, path: <WORKSPACE_NFS_EXPORT>/eg-wheel, readOnly: true}}
- {name: au-src, nfs: {server: <WORKSPACE_NFS_SERVER>, path: <WORKSPACE_NFS_EXPORT>/agent-packages/agent-utilities, readOnly: true}}
Not shown/unchanged: the graph-os Service (port 80 → container http) and
Ingress (graph-os.arpa, cookie affinity, TLS) need no edits at all — they already
target only the graph-os container's port 8000, which is unaffected by this cutover.
Remove (or scale to 0) the epistemic-graph Deployment and Service once the new pod is
checked — see the checklist.
Rollback¶
The redb single-writer lock makes rollback a mirror of cutover, not a special case:
- Scale the new
graph-os(pod with the sidecar) to 0 — releases the lock. - Scale the OLD
epistemic-graphDeployment back to 1 (this is why cutover step 3 scales to 0 rather than removing — keep it, its ConfigMap/Secret refs, and its Service around for the whole verification window before removing anything). kubectl rollout undo deployment/graph-os -n platform(or reapply the pre-cutover manifest) to restore the old pod spec (GRAPH_SERVICE_ENDPOINTS=tls://<ENGINE_NODE_IP>:9100via the durable config, no sidecar).- Confirm
graph-osreachesReadyagainst the restored separate engine before declaring rollback complete.
Total rollback window is the same ballpark as cutover step 4 (one redb reload) — there is no faster path given the single-writer lock; this is inherent to redb, not a gap in this design.
Validation checklist¶
- [ ] Pre-flight steps 1-4 all passed (copy-based rehearsal, kvcache confirmed, sole-consumer confirmed).
- [ ] New pod:
kubectl get pod -n platform -l app=graph-osshows the sidecarenginecontainerReadybefore thegraph-oscontainer even starts (confirm viakubectl describe podevent ordering — this is the native-sidecar contract, not just a hopeful timing). - [ ]
graph-os's/health(and/health/ready) report the engine reachable, mode consistent withunix://(nottls:///remote-network). - [ ] Graph count in the catalog matches the pre-cutover count noted in pre-flight step 1 (85-87 graphs per the most recent recorded count — confirm the CURRENT count immediately before cutover, don't reuse a stale figure).
- [ ] A representative KG query (e.g.
graph_query/graph_searchagainst__commons__or a knowncode_*graph) returns the same answer it did before cutover. - [ ] The kvcache check-before-cutover item resolved one way or the other, and the
resolution reflected in the sidecar's final
args. - [ ]
platform/epistemic-graphscaled to 0, not removed, until this checklist is fully green AND a rollback rehearsal (§Rollback) has been dry-run at least once. - [ ] Only after a soak period: remove the old
epistemic-graphDeployment + Service, and the now-unusedENGINE_CA_BUNDLE/ENGINE_TLS_SERVER_NAMEkeys fromgraph-os-env(left in place until then, per the inline comment above).
Pointers¶
| Concern | Source |
|---|---|
| The two-shapes edict + program status | reports/unified-binary-program.md; au AGENTS.md → "Engine transport"; eg AGENTS.md → PyO3/in-process section |
| Why this is possible now (the unified image) | docker/graphos-unified.Dockerfile |
| The multi-container-pod precedent this design reuses | services/epistemic-graph/k8s/bundled-core-pod.yaml (Variant A) |
| The identity/RBAC chain this design must not disturb | reports/HANDOFF-2026-07-22.md §1, §2e, §7 |
| The opposite (hyperscaling) shape — do not conflate | graphos-horizontal-scaling.md |
| Engine autostart precedence (why an explicit endpoint avoids it) | agent_utilities/knowledge_graph/core/engine_resolver.py |
| The redb single-writer lock | epistemic-graph src/main.rs (persist_lock::acquire) |