fix(supervise): get bot-bottle.db off the data plane (supervise + egress proposals over RPC) #471
@@ -33,11 +33,17 @@ from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
from ...log import die, info
|
||||
from ...paths import CONTROL_PLANE_TOKEN_FILENAME, bot_bottle_root
|
||||
from .. import util as backend_util
|
||||
from ..docker import util as docker_mod
|
||||
from ..docker.gateway_provision import GatewayProvisionError
|
||||
from . import firecracker_vm, infra_artifact, netpool, util
|
||||
|
||||
# Where the infra VM keeps its control-plane signing key (generated on the
|
||||
# persistent /dev/vdb volume mounted at BOT_BOTTLE_ROOT). The host mirrors it
|
||||
# back so the CLI signs `cli` tokens the VM verifies (issue #469 review).
|
||||
_GUEST_SIGNING_KEY_PATH = "/var/lib/bot-bottle/control-plane-token"
|
||||
|
||||
# The single infra-VM image: gateway data plane + baked control-plane source
|
||||
# (Dockerfile.infra FROM the gateway image). Built from source by default;
|
||||
# a pull-from-registry mode lands later.
|
||||
@@ -167,21 +173,45 @@ def ensure_running() -> InfraVm:
|
||||
want = _expected_version()
|
||||
if _adoptable(key, url, want):
|
||||
info(f"adopting running infra VM at {url}")
|
||||
return InfraVm(guest_ip=slot.guest_ip, private_key=key)
|
||||
return _with_signing_key(InfraVm(guest_ip=slot.guest_ip, private_key=key))
|
||||
|
||||
with _singleton_lock():
|
||||
# Re-check under the lock: another launcher may have booted it while
|
||||
# we waited for the lock (double-checked, so we adopt not re-boot).
|
||||
if _adoptable(key, url, want):
|
||||
info(f"adopting running infra VM at {url}")
|
||||
return InfraVm(guest_ip=slot.guest_ip, private_key=key)
|
||||
return _with_signing_key(InfraVm(guest_ip=slot.guest_ip, private_key=key))
|
||||
# Clear a stale/hung/OUTDATED VM holding the link before booting fresh.
|
||||
stop()
|
||||
ensure_built()
|
||||
infra = boot()
|
||||
wait_for_health(infra)
|
||||
_record_booted_version(want)
|
||||
return _with_signing_key(infra)
|
||||
|
||||
|
||||
def _with_signing_key(infra: InfraVm) -> InfraVm:
|
||||
"""Mirror the infra VM's control-plane signing key (generated on its
|
||||
persistent volume) into the host's control-plane-token file, so the host CLI
|
||||
signs `cli` tokens the VM verifies (issue #469 review). Best-effort: an
|
||||
unreadable key is logged, not fatal — the VM still enforces auth, but the CLI
|
||||
may then be rejected until the key is readable. Returns `infra` for chaining."""
|
||||
proc = subprocess.run(
|
||||
util.ssh_base_argv(infra.private_key, infra.guest_ip)
|
||||
+ [f"cat {_GUEST_SIGNING_KEY_PATH}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
signing_key = proc.stdout.strip()
|
||||
if proc.returncode != 0 or not signing_key:
|
||||
info("infra signing key not yet readable; control-plane auth may fail")
|
||||
return infra
|
||||
path = bot_bottle_root() / CONTROL_PLANE_TOKEN_FILENAME
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(signing_key)
|
||||
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
return infra
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -487,7 +517,18 @@ mount -t ext4 /dev/vdb /var/lib/bot-bottle 2>/dev/null || true
|
||||
|
||||
# Control plane. Source is baked at /app; the package is stdlib-only.
|
||||
cd /app
|
||||
BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -m bot_bottle.orchestrator \\
|
||||
# Control-plane signing key + a pre-minted `gateway` JWT, scoped per-process
|
||||
# (issue #469 review). The key is generated once on the persistent volume and
|
||||
# handed ONLY to the orchestrator (to verify tokens); the data-plane daemons get
|
||||
# the `gateway` JWT they present, never the key. Without this the control plane
|
||||
# would run OPEN and a compromised egress / supervise / git-http daemon in this
|
||||
# same VM — reaching the orchestrator over 127.0.0.1, past the nft boundary that
|
||||
# only fences off the separate agent VM — could drive the operator routes
|
||||
# (approve its own supervise proposals, rewrite policy, read injected tokens).
|
||||
CP_KEY=$(BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -c 'from bot_bottle.paths import host_control_plane_token as t; print(t())')
|
||||
GW_JWT=$(BB_SIGNING_KEY="$CP_KEY" python3 -c 'import os; from bot_bottle.control_auth import mint, ROLE_GATEWAY; print(mint(ROLE_GATEWAY, os.environ["BB_SIGNING_KEY"]))')
|
||||
|
||||
BOT_BOTTLE_ROOT=/var/lib/bot-bottle BOT_BOTTLE_CONTROL_PLANE_TOKEN="$CP_KEY" python3 -m bot_bottle.orchestrator \\
|
||||
--host 0.0.0.0 --port {CONTROL_PLANE_PORT} --broker stub &
|
||||
|
||||
# Gateway data plane, multi-tenant: each request resolves source-IP ->
|
||||
@@ -495,9 +536,12 @@ BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -m bot_bottle.orchestrator \\
|
||||
# git-http (9420), so the git:// daemon (git-gate, needs a per-bottle
|
||||
# entrypoint the consolidated model doesn't use) is left out. No
|
||||
# SUPERVISE_DB_PATH: the data plane reaches the supervise queue over the
|
||||
# control-plane RPC and never opens bot-bottle.db (PRD 0070 / #469).
|
||||
# control-plane RPC and never opens bot-bottle.db (PRD 0070 / #469). It presents
|
||||
# the pre-minted `gateway` JWT; gateway_init keeps the signing key out of the
|
||||
# data-plane daemons' env.
|
||||
BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\
|
||||
BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\
|
||||
BOT_BOTTLE_CONTROL_AUTH_JWT="$GW_JWT" \\
|
||||
python3 -m bot_bottle.gateway_init &
|
||||
|
||||
# Reap as PID 1; children are backgrounded, so `wait` blocks.
|
||||
|
||||
@@ -8,6 +8,7 @@ decisions that must hold without a VM.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -15,6 +16,37 @@ from unittest.mock import MagicMock, patch
|
||||
from bot_bottle.backend.firecracker import infra_vm
|
||||
|
||||
|
||||
class TestSigningKeySync(unittest.TestCase):
|
||||
"""`_with_signing_key` mirrors the VM's control-plane signing key into the
|
||||
host token file so the CLI signs `cli` tokens the VM verifies (issue #469)."""
|
||||
|
||||
def _infra(self) -> infra_vm.InfraVm:
|
||||
return infra_vm.InfraVm(guest_ip="10.0.0.1", private_key=Path("/k"))
|
||||
|
||||
def test_mirrors_guest_key_to_host_file(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
root = Path(d)
|
||||
proc = MagicMock(returncode=0, stdout="the-signing-key\n")
|
||||
with patch.object(infra_vm.subprocess, "run", return_value=proc) as run, \
|
||||
patch.object(infra_vm, "bot_bottle_root", return_value=root):
|
||||
out = infra_vm._with_signing_key(self._infra())
|
||||
self.assertIsInstance(out, infra_vm.InfraVm) # returned for chaining
|
||||
token = root / infra_vm.CONTROL_PLANE_TOKEN_FILENAME
|
||||
self.assertEqual("the-signing-key", token.read_text())
|
||||
self.assertEqual(0o600, token.stat().st_mode & 0o777)
|
||||
# It cat'd the guest volume path over SSH.
|
||||
self.assertIn(infra_vm._GUEST_SIGNING_KEY_PATH, run.call_args.args[0][-1])
|
||||
|
||||
def test_unreadable_key_is_not_fatal(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
root = Path(d)
|
||||
proc = MagicMock(returncode=255, stdout="")
|
||||
with patch.object(infra_vm.subprocess, "run", return_value=proc), \
|
||||
patch.object(infra_vm, "bot_bottle_root", return_value=root):
|
||||
infra_vm._with_signing_key(self._infra()) # no raise
|
||||
self.assertFalse((root / infra_vm.CONTROL_PLANE_TOKEN_FILENAME).exists())
|
||||
|
||||
|
||||
class TestControlPlaneUrl(unittest.TestCase):
|
||||
def test_url_uses_guest_ip_and_port(self):
|
||||
infra = infra_vm.InfraVm(
|
||||
@@ -46,6 +78,14 @@ class TestBuildInfraRootfs(unittest.TestCase):
|
||||
self.assertIn("/dev/vdb", init)
|
||||
# VM backend uses git-http (9420); the git:// daemon is left out.
|
||||
self.assertIn("BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise", init)
|
||||
# Role-scoped control-plane auth (issue #469 review): the orchestrator
|
||||
# gets the signing key, the gateway daemons get a pre-minted `gateway`
|
||||
# JWT — never open mode in the infra VM.
|
||||
self.assertIn("host_control_plane_token", init) # key generated on the volume
|
||||
self.assertIn("mint, ROLE_GATEWAY", init) # gateway JWT minted from it
|
||||
self.assertIn('BOT_BOTTLE_CONTROL_PLANE_TOKEN="$CP_KEY" python3 -m bot_bottle.orchestrator',
|
||||
init) # key -> orchestrator only
|
||||
self.assertIn('BOT_BOTTLE_CONTROL_AUTH_JWT="$GW_JWT"', init) # JWT -> gateway daemons
|
||||
|
||||
|
||||
class TestSshGatewayTransport(unittest.TestCase):
|
||||
|
||||
Reference in New Issue
Block a user