docs(prd): 0070 per-host orchestrator service #352

Closed
didericis-claude wants to merge 12 commits from prd-orchestrator into main
2 changed files with 46 additions and 33 deletions
Showing only changes of commit b174442c60 - Show all commits
+16 -9
View File
@@ -1,10 +1,15 @@
"""Orchestrator bottle registry — the per-host runtime-state store (PRD 0070).
SQLite-backed (WAL) registry mapping each live bottle to its source IP and
SQLite-backed registry mapping each live bottle to its source IP and
per-bottle identity token, plus the **fail-closed attribution** the data
plane relies on: a request is attributed to a bottle only when its source
IP *and* its identity token both match a single active record.
The registry co-tenants the shared host `bot-bottle.db` (the `DbStore`
framework namespaces each store by `schema_key`), so all bot-bottle
runtime state lives in one queryable file — one place to back up, inspect,
and integrate a console against.
This is the "runtime state" tier of PRD 0070 (leases / approvals /
registry) — deliberately NOT config (which stays declarative under
`~/.bot-bottle/`) and NOT the build-time constants (a flat file). It is the
@@ -32,6 +37,7 @@ from pathlib import Path
from ..db_store import DbStore
from ..migrations import TableMigrations
from ..supervise_types import host_db_path
Outdated
Review

this should be moved to db_store

this should be moved to db_store
# 256 bits of urandom, URL-safe — unguessable per-bottle identity token.
IDENTITY_TOKEN_BYTES = 32
1
@@ -43,9 +49,10 @@ def new_identity_token() -> str:
def default_db_path() -> Path:
"""Host location for the orchestrator's runtime-state DB. Runtime state
(not config), so it lives under the cache dir like the pool state."""
return Path.home() / ".cache" / "bot-bottle" / "orchestrator" / "registry.db"
"""The shared host state DB (`bot-bottle.db`) — all bot-bottle runtime
state in one file, co-tenanted via schema_key. Host-resident so it
survives orchestrator restarts (re-adoption sweeps it)."""
return host_db_path()
@dataclass(frozen=True)
@@ -113,12 +120,12 @@ class RegistryStore(DbStore):
super().__init__(db_path or default_db_path(), _MIGRATIONS)
def _connect(self) -> sqlite3.Connection:
"""Open a WAL connection (concurrent data-plane reads + writer)."""
"""Open a connection with a busy timeout for the shared DB."""
conn = super()._connect()
# WAL lets the data-plane attribution reads run concurrently with the
# control-plane writer without blocking; busy_timeout avoids spurious
# "database is locked" under that concurrency (PRD 0070 state tier).
conn.execute("PRAGMA journal_mode=WAL")
# The registry co-tenants the shared bot-bottle.db, so a busy_timeout
# rides out brief lock contention with the other stores. WAL for the
# shared DB is a deliberate future change (it affects supervise/audit
# and is finicky over guest shares) — not flipped here.
conn.execute("PRAGMA busy_timeout=5000")
return conn
+30 -24
View File
1
@@ -261,7 +261,7 @@ piling methods on — the same discipline, recursed.
The orchestrator is the natural owner of per-host **runtime state**:
- pool **slot leases** (which bottle holds slot *i*) — replaces today's
`fcntl`-locked files with WAL-mode transactions;
`fcntl`-locked files with SQLite transactions;
- the **supervise approval queue** + remembered approvals;
- the **live bottle registry** (source IP → bottle → policy/secrets refs),
the lookup table the attribution invariant reads.
@@ -273,38 +273,44 @@ Config splits into three tiers with different homes:
|---|---|---|
| Build-time constants | pool size, IP base, nft table | flat `.env` (PR #350) — must be readable by Nix eval + root bash, zero runtime |
| User-authored config | bottle manifests, egress routes, secret refs | declarative files under `~/.bot-bottle/` — trust boundary at `$HOME`, git-trackable, "unknown keys die at load" |
| Runtime state | slot leases, approvals, registry | **SQLite**, owned by the orchestrator |
| Runtime state | slot leases, approvals, registry | one shared **`bot-bottle.db`**, solely owned by the orchestrator |
SQLite is right for the runtime tier (mutable, concurrent, queried) and
wrong for the other two (Nix can't read it at eval time; it fights the
declarative manifest trust model). Keep the tiers separate.
**The DB file is host-resident, not owned inside the orchestrator unit**
(decided in review). Two reasons:
**One shared `bot-bottle.db` for all runtime state** (decided in review).
The registry co-tenants the existing host `bot-bottle.db` — the `DbStore`
framework already namespaces each store by `schema_key`, so slot
leases / approvals / registry share one file. One place to query, back up,
and integrate a console against.
- **Durability across restarts.** Re-adoption sweeps the registry after an
orchestrator restart, so the state *must* outlive the orchestrator
instance's lifecycle. A host-resident file (the slice-1 default under
`~/.cache/bot-bottle/orchestrator/`) is the simplest durable store; the
orchestrator VM/container mounts it rather than carrying it.
- **Integrity via access-scoping, not location.** Agents can't touch the
DB directly regardless of where it lives — they're network-isolated in
their bottles and only ever speak to the control/data plane. The real
risk is a *compromised agent-facing data-plane service* (egress/git-gate,
which parse hostile bytes) writing the registry and forging attribution.
The control is a **read/write split**: writes (register/deregister) come
only from the **control plane**; the **data plane gets the DB read-only**
for attribution lookups. A host-resident file makes that split
enforceable (mount `ro` into the data plane, `rw` into the control
plane) — which owning it "entirely inside" a monolithic orchestrator
would not.
- **Host-resident, for durability.** Re-adoption sweeps the registry after
an orchestrator restart, so state *must* outlive the orchestrator
instance. The file lives on the host (`bot_bottle_root()/db/bot-bottle.db`);
the orchestrator unit reaches it, it doesn't carry it.
- **Integrity by sole ownership, not mount permissions.** Agents can't
touch the DB directly wherever it lives (network-isolated in their
bottles). The risk is a *compromised agent-facing data-plane service*
(egress/git-gate, which parse hostile bytes) writing the registry and
forging attribution. Because it's now one shared file, coarse `ro`/`rw`
mount-splitting no longer isolates the registry — so the rule is stronger
and simpler: **only the orchestrator (control plane) opens `bot-bottle.db`;
the data plane and the console reach state through the control-plane RPC,
never a direct file handle.** No agent-facing component gets the file, so
none can forge attribution. (This supersedes the earlier `ro`-mount idea.)
- *Transitional caveat:* today the per-bottle **supervise sidecar
rw-bind-mounts `bot-bottle.db`** to write proposals — exactly the
pattern the orchestrator removes (supervise consolidates into the
didericis marked this conversation as resolved Outdated
Outdated
Review

It’s worth it, should be a minimal surface for a malicious agent to exploit/we should be able to harden it and keep it secure.

Also spec-ed out more mitigations via short lived vault tokens (link the relevant issue in reply to this comment)

It’s worth it, should be a minimal surface for a malicious agent to exploit/we should be able to harden it and keep it secure. Also spec-ed out more mitigations via short lived vault tokens (link the relevant issue in reply to this comment)
orchestrator; sidecar writes become RPC calls). Until that lands, don't
put the attribution registry behind a data-plane-writable mount.
didericis marked this conversation as resolved Outdated
Outdated
Review

Whatever is most universal/reliable on every host machine (probably http?)

Whatever is most universal/reliable on every host machine (probably http?)
Implementation note for the VM slices: SQLite **WAL** over a guest share
(virtiofs/9p) is finicky (the `-shm`/`-wal` files need real mmap/locking),
didericis marked this conversation as resolved Outdated
Outdated
Review

Can you elaborate racing in flight launches? You mean possible parallel orchestrator relaunches on the same host, or an or orchestrator possibly restarting an agent before readopting it?

Regardless (unless I’m missing something), I think the procedure should be fairly straightforward:

  1. every orchestrator launch requires there to be no preexisting, healthy orchestrator launch. If a healthy or non healthy orchestrator is found, it must be fully removed/shut down first
  2. readoption requires waiting for the new orchestrator to be healthy
  3. once the new orchestrator is healthy, the orchestrator must search to find all agents needing adoption (via both sqlite state and process inspection) before handling any other requests
Can you elaborate racing in flight launches? You mean possible parallel orchestrator relaunches on the same host, or an or orchestrator possibly restarting an agent before readopting it? Regardless (unless I’m missing something), I think the procedure should be fairly straightforward: 1) every orchestrator launch requires there to be no preexisting, healthy orchestrator launch. If a healthy or non healthy orchestrator is found, it must be fully removed/shut down first 2) readoption requires waiting for the new orchestrator to be healthy 3) once the new orchestrator is healthy, the orchestrator must search to find all agents needing adoption (via both sqlite state and process inspection) before handling any other requests
so the durable-DB-on-host may want a small **host-side owner** the
orchestrator reaches over the control-plane RPC, rather than a shared
mount. Decide this at the Firecracker slice; `sqlite3` itself is stdlib, so
"the host needs SQLite" is a non-cost.
which is a second reason the DB wants a **host-side owner** the orchestrator
reaches over the RPC rather than a shared mount into the VM. WAL on the
didericis marked this conversation as resolved Outdated
Outdated
Review

Guessing this will have to vary per backend, but think we’ve solved this already? Had spikes related to putting sidecars in vms where I think we figured out how to do this

Guessing this will have to vary per backend, but think we’ve solved this already? Had spikes related to putting sidecars in vms where I think we figured out how to do this
shared DB is therefore a deliberate, tested future change — not enabled ad
hoc. `sqlite3` itself is stdlib, so "the host needs SQLite" is a non-cost.
didericis marked this conversation as resolved Outdated
Outdated
Review

human readable json with static flags and ids makes sense for all communication with the side, I think. Might be missing cases where that doesn’t work, but we’ll tackle them as we go/make that the ideal. Would also be worth making it a signed jwt for provenance

human readable json with static flags and ids makes sense for all communication with the side, I think. Might be missing cases where that doesn’t work, but we’ll tackle them as we go/make that the ideal. Would also be worth making it a signed jwt for provenance
## Sequencing