Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4434752db4 | |||
| f9d0e15f13 | |||
| bf6b32381d |
@@ -19,7 +19,6 @@ singleton flock) is `FirecrackerInfraService` (`infra.py`).
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from ...gateway import (
|
||||
@@ -28,7 +27,6 @@ from ...gateway import (
|
||||
GatewayError,
|
||||
GatewayTransport,
|
||||
)
|
||||
from ...log import die, info
|
||||
from .. import util as backend_util
|
||||
from . import infra_vm, netpool, util
|
||||
from .gateway_transport import FirecrackerGatewayTransport
|
||||
@@ -51,16 +49,6 @@ _GUEST_GATEWAY_JWT_PATH = infra_vm._GUEST_GATEWAY_JWT_PATH
|
||||
# the gateway's TLS interception. Host-side only (SSH cat), so it lives here.
|
||||
_GATEWAY_CA_PATH = "/home/mitmproxy/.mitmproxy/mitmproxy-ca-cert.pem"
|
||||
|
||||
# The gateway VM's persistent CA volume — a small ext4 file the gateway init
|
||||
# mounts at mitmproxy's confdir (see `infra_vm._GATEWAY_CA_MOUNT`) so the
|
||||
# self-generated CA SURVIVES a gateway-VM rebuild/restart. Without it the CA
|
||||
# lives only in the ephemeral per-boot rootfs, so every rebuild mints a fresh CA
|
||||
# that every already-running bottle distrusts, failing the TLS handshake (the
|
||||
# firecracker analogue of the docker fix's persistent CA bind-mount — issue
|
||||
# #450). Co-located with the orchestrator's registry volume under the infra dir,
|
||||
# which outlives the ephemeral rootfs. mitmproxy is tiny; 16M is ample.
|
||||
_CA_VOLUME_SIZE = "16M"
|
||||
|
||||
_CA_FETCH_TIMEOUT_SECONDS = 15.0
|
||||
|
||||
|
||||
@@ -106,15 +94,11 @@ class FirecrackerGateway(Gateway):
|
||||
f"cannot resolve orchestrator guest IP from {self._orchestrator_url!r}"
|
||||
)
|
||||
# Boot on the gateway link from the gateway rootfs, then push the token
|
||||
# the init waits for before starting the data plane. The persistent CA
|
||||
# volume (/dev/vdb, mounted at mitmproxy's confdir by the gateway init)
|
||||
# keeps the CA STABLE across rebuilds so already-running bottles keep
|
||||
# trusting the gateway's TLS interception (issue #450).
|
||||
# the init waits for before starting the data plane.
|
||||
vm = infra_vm.boot_vm(
|
||||
name=GATEWAY_NAME, slot=netpool.gw_slot(), run_dir=infra_vm._gw_dir(),
|
||||
role="gateway", mem_mib=_GW_MEM_MIB,
|
||||
extra_boot_args=f"bb_orch={orchestrator_guest_ip}",
|
||||
data_drive=self._ensure_ca_volume(),
|
||||
)
|
||||
infra_vm.push_secret(
|
||||
vm, self._gateway_token, _GUEST_GATEWAY_JWT_PATH,
|
||||
@@ -132,26 +116,6 @@ class FirecrackerGateway(Gateway):
|
||||
infra_vm._kill_pidfile(infra_vm._gw_dir())
|
||||
infra_vm._pid_file(infra_vm._gw_dir()).unlink(missing_ok=True)
|
||||
|
||||
def _ensure_ca_volume(self) -> Path:
|
||||
"""Create the empty ext4 CA volume on first use; reuse it after.
|
||||
|
||||
A fresh (empty) volume makes mitmproxy generate a CA into it on first
|
||||
boot; every later boot reuses the CA already on the volume — which is
|
||||
what keeps the CA stable across gateway-VM rebuilds. The mirror of
|
||||
`FirecrackerOrchestrator._ensure_registry_volume`."""
|
||||
vol = infra_vm._gw_dir() / "gateway-ca.ext4"
|
||||
if vol.exists():
|
||||
return vol
|
||||
info(f"creating gateway CA volume {vol} ({_CA_VOLUME_SIZE})")
|
||||
proc = subprocess.run(
|
||||
["mke2fs", "-q", "-t", "ext4", "-F", str(vol), _CA_VOLUME_SIZE],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
vol.unlink(missing_ok=True)
|
||||
die(f"creating gateway CA volume failed: {proc.stderr.strip()}")
|
||||
return vol
|
||||
|
||||
def address(self) -> str:
|
||||
"""The gateway VM's guest IP — the agent-facing target agent VMs'
|
||||
gateway-port traffic is DNAT'd to."""
|
||||
|
||||
@@ -53,16 +53,10 @@ from . import firecracker_vm, infra_artifact, netpool, util
|
||||
# tokens with the same key the host CLI signs with — the host token file stays
|
||||
# the single source of truth, never clobbered per-backend (issue #469 review).
|
||||
_GUEST_SIGNING_KEY_PATH = "/var/lib/bot-bottle/orchestrator-token"
|
||||
# The gateway VM's pre-minted `gateway` JWT path (rootfs, not /dev/vdb — the JWT
|
||||
# is re-pushed every boot, so it needn't persist). Pushed post-boot; the gateway
|
||||
# daemons present it to the orchestrator, and never see the key.
|
||||
# The gateway VM's pre-minted `gateway` JWT path (rootfs, not /dev/vdb — the
|
||||
# data plane has no registry volume and never opens the DB). Pushed post-boot;
|
||||
# the gateway daemons present it to the orchestrator, and never see the key.
|
||||
_GUEST_GATEWAY_JWT_PATH = "/var/lib/bot-bottle/gateway-jwt"
|
||||
# The gateway VM's persistent CA volume mount point — mitmproxy's confdir. The
|
||||
# gateway boots with a persistent /dev/vdb CA volume (see
|
||||
# `FirecrackerGateway._ensure_ca_volume`) mounted here so the self-generated CA
|
||||
# survives a gateway-VM rebuild; without it every rebuild mints a fresh CA that
|
||||
# already-running bottles distrust, breaking the TLS handshake (issue #450).
|
||||
_GATEWAY_CA_MOUNT = "/home/mitmproxy/.mitmproxy"
|
||||
|
||||
# The two per-plane rootfs source images. The orchestrator VM boots a control
|
||||
# plane + buildah rootfs (Dockerfile.orchestrator.fc, FROM orchestrator); the
|
||||
@@ -454,13 +448,6 @@ def _gateway_init() -> str:
|
||||
bot-bottle.db (PRD 0070 / #469). If the JWT never arrives, REFUSE to start
|
||||
rather than run without auth."""
|
||||
return _init_head() + f"""
|
||||
# Persistent CA volume (second virtio-block device, /dev/vdb) mounted at
|
||||
# mitmproxy's confdir, so the self-generated mitmproxy CA survives gateway-VM
|
||||
# rebuilds (issue #450). On first boot the volume is empty and mitmproxy mints a
|
||||
# CA into it; every later boot reuses it. Must mount BEFORE the data plane (hence
|
||||
# mitmproxy) starts.
|
||||
mkdir -p {_GATEWAY_CA_MOUNT}
|
||||
mount -t ext4 /dev/vdb {_GATEWAY_CA_MOUNT} 2>/dev/null || true
|
||||
ORCH=$(sed -n 's/.*bb_orch=\\([^ ]*\\).*/\\1/p' /proc/cmdline)
|
||||
GW_JWT=""
|
||||
i=0
|
||||
|
||||
@@ -184,6 +184,27 @@ class OrchestratorClient:
|
||||
)
|
||||
return True
|
||||
|
||||
def update_agent_secret(
|
||||
self, bottle_id: str, name: str, value: str, env_var_secret: str,
|
||||
) -> bool:
|
||||
"""Update ONE egress token for a running bottle in place
|
||||
(`POST /bottles/<id>/secret`) — the single-secret form of
|
||||
`reprovision_gateway`, for pushing a freshly-refreshed host credential
|
||||
into a bottle without a relaunch. Returns True on success, False when the
|
||||
orchestrator doesn't know the bottle (404)."""
|
||||
status, _ = self._request(
|
||||
"POST",
|
||||
f"/bottles/{bottle_id}/secret",
|
||||
{"name": name, "value": value, "env_var_secret": env_var_secret},
|
||||
)
|
||||
if status == 404:
|
||||
return False
|
||||
if not 200 <= status < 300:
|
||||
raise OrchestratorClientError(
|
||||
f"update_agent_secret {bottle_id}: HTTP {status}"
|
||||
)
|
||||
return True
|
||||
|
||||
def teardown_bottle(self, bottle_id: str) -> bool:
|
||||
"""Tear a bottle down (`DELETE /bottles/<id>`). False if the
|
||||
orchestrator didn't know it (404) — idempotent for cleanup paths."""
|
||||
|
||||
@@ -200,6 +200,35 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
||||
return 200, {"reprovisioned": True}
|
||||
return 404, {"error": "no stored secrets for this bottle"}
|
||||
|
||||
if (
|
||||
method == "POST"
|
||||
and route.startswith("/bottles/")
|
||||
and route.endswith("/secret")
|
||||
):
|
||||
# Update ONE egress token for a running bottle in place — the
|
||||
# single-secret form of reprovision, for refreshing a short-lived host
|
||||
# credential (e.g. the Codex access token) without a relaunch. cli-only
|
||||
# (not in _GATEWAY_ROUTES): a bottle must never set its own tokens.
|
||||
bottle_id = route[len("/bottles/") : -len("/secret")]
|
||||
try:
|
||||
data = _parse_json_object(body)
|
||||
except ValueError as e:
|
||||
return 400, {"error": f"invalid JSON: {e}"}
|
||||
name = data.get("name")
|
||||
value = data.get("value")
|
||||
env_var_secret = data.get("env_var_secret")
|
||||
if (
|
||||
not isinstance(name, str) or not name
|
||||
or not isinstance(value, str) or not value
|
||||
or not isinstance(env_var_secret, str) or not env_var_secret
|
||||
):
|
||||
return 400, {
|
||||
"error": "name, value, and env_var_secret (non-empty strings) are required"
|
||||
}
|
||||
if orch.update_agent_secret(bottle_id, name, value, env_var_secret):
|
||||
return 200, {"updated": True}
|
||||
return 404, {"error": "no such bottle"}
|
||||
|
||||
if method == "DELETE" and route.startswith("/bottles/"):
|
||||
bottle_id = route[len("/bottles/"):]
|
||||
if orch.teardown_bottle(bottle_id):
|
||||
|
||||
@@ -377,6 +377,30 @@ class OrchestratorCore:
|
||||
return False
|
||||
return True
|
||||
|
||||
def update_agent_secret(
|
||||
self, bottle_id: str, name: str, value: str, env_var_secret: str,
|
||||
) -> bool:
|
||||
"""Update ONE egress token for a known bottle in place — the
|
||||
single-secret form of ``reprovision_from_secret``.
|
||||
|
||||
Sets the in-memory token AND upserts the single re-encrypted row under
|
||||
*env_var_secret* (the same key the rest of the rows are encrypted with, so
|
||||
the whole set stays decryptable by a later ``reprovision_from_secret``),
|
||||
leaving every other token untouched. Returns False if the bottle is
|
||||
unknown.
|
||||
|
||||
Unlike ``reprovision_from_secret`` (which restores the values captured at
|
||||
launch), this pushes a *caller-supplied* value — used to refresh a
|
||||
short-lived host credential (e.g. the Codex access token) into a
|
||||
still-running bottle without a relaunch."""
|
||||
from .store.secret_store import encrypt_value
|
||||
if self.registry.get(bottle_id) is None:
|
||||
return False
|
||||
self._tokens.setdefault(bottle_id, {})[name] = value
|
||||
self.registry.store_agent_secret(
|
||||
bottle_id, name, encrypt_value(env_var_secret, value))
|
||||
return True
|
||||
|
||||
# --- consolidated gateway ----------------------------------------------
|
||||
|
||||
def gateway_status(self) -> dict[str, object]:
|
||||
|
||||
@@ -366,6 +366,30 @@ class RegistryStore(DbStore):
|
||||
)
|
||||
self._chmod()
|
||||
|
||||
def store_agent_secret(
|
||||
self,
|
||||
bottle_id: str,
|
||||
key: str,
|
||||
encrypted_value: str,
|
||||
secret_type: str = "injected_env_var",
|
||||
) -> None:
|
||||
"""Upsert ONE encrypted secret row (env-var name → ciphertext) for
|
||||
*bottle_id*, leaving the bottle's other secrets untouched — the per-key
|
||||
counterpart of ``store_agent_secrets``' replace-all. Delete-then-insert
|
||||
because the table carries no unique constraint to `ON CONFLICT` against."""
|
||||
with self._connection() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM bottled_agent_secrets "
|
||||
"WHERE bottled_agent_id = ? AND key = ? AND type = ?",
|
||||
(bottle_id, key, secret_type),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO bottled_agent_secrets "
|
||||
"(bottled_agent_id, key, value, type) VALUES (?, ?, ?, ?)",
|
||||
(bottle_id, key, encrypted_value, secret_type),
|
||||
)
|
||||
self._chmod()
|
||||
|
||||
def get_agent_secrets(
|
||||
self,
|
||||
bottle_id: str,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# PRD 0081: Reprovision gateway-dependent state on gateway bring-up
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** claude
|
||||
- **Created:** 2026-07-26
|
||||
- **Issue:** #510, #512
|
||||
|
||||
## Summary
|
||||
|
||||
When the gateway is (re)built, reconcile every already-running bottle against the
|
||||
fresh gateway instead of persisting the gateway's state. On a gateway cold boot,
|
||||
the gateway reconciles all live bottles in one flow: **replace** each agent's
|
||||
trusted CA with the freshly-minted gateway CA, **re-provision** each bottle's
|
||||
git-gate repos + creds onto the gateway, and **restore** each bottle's egress
|
||||
tokens. One mechanism across all three services; the CA rotates for free on every
|
||||
bring-up.
|
||||
|
||||
## Problem
|
||||
|
||||
A gateway rebuild/restart silently breaks every already-running bottle:
|
||||
|
||||
- **CA (#510).** The gateway's mitmproxy mints a new CA on a fresh rootfs; agents
|
||||
still trust the old one, so egress fails TLS verification (`SSL certificate
|
||||
verification failed`).
|
||||
- **git-gate (#512).** Per-bottle bare repos (`/git/<id>`) + deploy creds
|
||||
(`/git-gate/creds/<id>`) live in the gateway's ephemeral rootfs; a rebuild wipes
|
||||
them and the agent 404s on fetch/push.
|
||||
|
||||
Both are the same root cause: per-bottle gateway-dependent state is provisioned
|
||||
**once, at bottle launch**, and nothing restores it for already-running bottles
|
||||
when the gateway comes back. The existing launch-time reprovision
|
||||
(`reprovision_bottles`) restores **only** egress tokens, and only as a side effect
|
||||
of the *next* launch.
|
||||
|
||||
Persisting the state (a host bind-mount / a per-VM data volume per service) was
|
||||
prototyped and rejected: it differs per service (a volume for the CA, another for
|
||||
git-gate, reprovision for tokens), it pins the CA static forever (no rotation),
|
||||
and it adds volume surface that `docker volume prune` / a wiped cache can silently
|
||||
destroy (the original #450 failure mode).
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- A gateway (re)boot restores **all** running bottles' gateway-dependent state
|
||||
with no manual step and no relaunch — the agent's next egress / fetch / push
|
||||
just works.
|
||||
- **One** reconcile flow covering CA, git-gate, and egress tokens, rather than a
|
||||
different mechanism per service.
|
||||
- The CA **rotates** on every gateway bring-up (no long-lived CA), distributed to
|
||||
running agents by the same reconcile.
|
||||
- Per-bottle failures are tolerated: one unreachable or malformed bottle does not
|
||||
block the others or the gateway coming up.
|
||||
- **All backends** (Firecracker, docker, macOS) reconcile through the *same*
|
||||
contract — an abstract method on the backend base class, so a new backend
|
||||
cannot forget to implement it and none drifts onto a bespoke mechanism.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Deliberate mid-session CA rotation *without* a gateway restart — `rotate_ca`
|
||||
stays for that operator action.
|
||||
- Changing source-IP attribution, `/resolve`, or the plane split (#469).
|
||||
|
||||
## Design
|
||||
|
||||
**The contract — `attach_bottled_agents_to_gateway()` on the backend ABC.**
|
||||
Reconciling running bottles against the current gateway is a backend
|
||||
responsibility (only the backend can enumerate its agents and reach them —
|
||||
firecracker over SSH, docker/macOS over `exec`/`cp`), so it is an
|
||||
`@abc.abstractmethod` on `BottleBackend` (`backend/base.py`). Every backend
|
||||
implements it; the host calls it whenever the gateway is (re)brought up. This is
|
||||
what makes the fix cross-backend by construction rather than a per-backend
|
||||
follow-up. It reprovisions **all** registered bottles' gateway-dependent state:
|
||||
CA, git-gate, and egress tokens.
|
||||
|
||||
**Trigger — the gateway bring-up path.** The host calls
|
||||
`attach_bottled_agents_to_gateway()` only when the gateway was actually
|
||||
(re)brought up — the cold-boot branch of the infra bring-up (e.g.
|
||||
`FirecrackerInfraService.ensure_running` after it boots a fresh pair), never on an
|
||||
adopt of a healthy, current gateway (state intact). So it fires exactly when the
|
||||
gateway was (re)booted — including orchestrator restarts, since the pair boots
|
||||
together — and there is no bare-restart path that bypasses bring-up.
|
||||
|
||||
**Per-backend implementation.** Each backend's `attach_bottled_agents_to_gateway`
|
||||
enumerates its live bottles and, for each, reconciles the three services against
|
||||
the current gateway. The firecracker implementation, once the gateway VM is up
|
||||
and its CA is available:
|
||||
|
||||
1. Map each live bottle's guest IP → `bottle_id` from the orchestrator registry
|
||||
(`list_bottles`).
|
||||
2. Install the **current** shared git-gate hooks (`git_gate_render_hook` /
|
||||
`git_gate_render_access_hook`) into the fresh gateway once — rendered from
|
||||
code, never from a bottle's possibly-stale state dir.
|
||||
3. For each live agent VM (enumerated from its run dir):
|
||||
- **CA:** SSH the current gateway CA into the agent's trust store and run
|
||||
`update-ca-certificates` (unconditional replace — there is one gateway, so no
|
||||
fingerprint match is needed).
|
||||
- **git-gate:** rebuild the bottle's upstreams from its persisted git-gate
|
||||
state dir (deploy key, known_hosts, upstream URL) and re-init its bare repos
|
||||
+ per-repo creds under `/git/<bottle_id>`.
|
||||
- **egress token:** read the agent's `ENV_VAR_SECRET` and feed
|
||||
`reprovision_bottles`, restoring the orchestrator's in-memory tokens.
|
||||
4. Every per-bottle step is wrapped so one failure is logged and skipped.
|
||||
|
||||
**Retire the persistence prototype.** No CA data volume, no git-gate data volume
|
||||
(the abandoned PRs #511 / #513). The launch-time `_reprovision_running_bottles`
|
||||
call folds into this bring-up reconcile, so egress tokens are restored on the same
|
||||
cold-boot trigger (an adopt needs no restore — the orchestrator never restarted).
|
||||
|
||||
**CA rotation.** Because the gateway rootfs is ephemeral, every cold boot mints a
|
||||
fresh CA; reconcile is what distributes it, so a routine gateway rebuild doubles
|
||||
as a CA rotation with zero extra machinery.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Docker's existing host-bind-mounted CA (`host_gateway_ca_dir`): once docker's
|
||||
`attach_bottled_agents_to_gateway` pushes the CA to running agents on bring-up,
|
||||
the bind-mount is redundant — drop it (so docker rotates like firecracker) or
|
||||
keep it as belt-and-suspenders? Leaning drop, for one behaviour across backends.
|
||||
@@ -59,10 +59,7 @@ class TestFirecrackerGatewayConnect(unittest.TestCase):
|
||||
def test_boots_the_gateway_vm_and_seeds_the_token(self) -> None:
|
||||
gw = FirecrackerGateway()
|
||||
booted = infra_vm.InfraVm(guest_ip="10.243.255.3", private_key=Path("/k"))
|
||||
ca_vol = Path("/gw/gateway-ca.ext4")
|
||||
with patch.object(infra_vm, "boot_vm", return_value=booted) as boot, \
|
||||
patch.object(FirecrackerGateway, "_ensure_ca_volume",
|
||||
return_value=ca_vol), \
|
||||
patch.object(infra_vm, "push_secret") as push:
|
||||
gw.connect_to_orchestrator(_ORCH_URL, _TOKEN)
|
||||
# Booted on the gateway link with the gateway role; the orchestrator's
|
||||
@@ -70,43 +67,12 @@ class TestFirecrackerGatewayConnect(unittest.TestCase):
|
||||
kw = boot.call_args.kwargs
|
||||
self.assertEqual("gateway", kw["role"])
|
||||
self.assertIn("bb_orch=10.243.255.1", kw["extra_boot_args"])
|
||||
# The persistent CA volume rides as /dev/vdb so the mitmproxy CA
|
||||
# survives a gateway-VM rebuild (issue #450).
|
||||
self.assertEqual(ca_vol, kw.get("data_drive"))
|
||||
self.assertIsNone(kw.get("data_drive")) # data plane never opens the DB
|
||||
# The host-minted token (never the key — #469) is pushed to the guest.
|
||||
push.assert_called_once()
|
||||
self.assertEqual(_TOKEN, push.call_args.args[1])
|
||||
|
||||
|
||||
class TestGatewayCaVolume(unittest.TestCase):
|
||||
"""The persistent CA volume that keeps the mitmproxy CA stable across a
|
||||
gateway-VM rebuild (issue #450) — the mirror of the orchestrator's registry
|
||||
volume."""
|
||||
|
||||
def test_reuses_existing_volume(self) -> None:
|
||||
gw = FirecrackerGateway()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
vol = Path(td) / "gateway-ca.ext4"
|
||||
vol.write_bytes(b"") # already present
|
||||
with patch.object(infra_vm, "_gw_dir", return_value=Path(td)), \
|
||||
patch(f"{_GW}.subprocess.run") as run:
|
||||
out = gw._ensure_ca_volume()
|
||||
run.assert_not_called() # no mke2fs when it exists — the CA survives
|
||||
self.assertEqual(vol, out)
|
||||
|
||||
def test_creates_volume_when_missing(self) -> None:
|
||||
gw = FirecrackerGateway()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
with patch.object(infra_vm, "_gw_dir", return_value=Path(td)), \
|
||||
patch(f"{_GW}.subprocess.run",
|
||||
return_value=CompletedProcess([], 0)) as run:
|
||||
out = gw._ensure_ca_volume()
|
||||
argv = run.call_args.args[0]
|
||||
self.assertIn("mke2fs", argv)
|
||||
self.assertEqual(str(Path(td) / "gateway-ca.ext4"), out.__fspath__())
|
||||
self.assertIn(str(out), argv)
|
||||
|
||||
|
||||
class TestFirecrackerGatewaySurface(unittest.TestCase):
|
||||
def test_is_running_reads_the_gateway_pidfile(self) -> None:
|
||||
gw = FirecrackerGateway()
|
||||
|
||||
@@ -61,11 +61,7 @@ class TestRoleInits(unittest.TestCase):
|
||||
init = infra_vm.role_init("gateway")
|
||||
self.assertNotIn("bb_role=", init)
|
||||
self.assertNotIn("bot_bottle.orchestrator", init)
|
||||
# Persistent CA volume mounted at mitmproxy's confdir so the CA survives
|
||||
# a gateway-VM rebuild (issue #450). Mounted BEFORE the data plane starts.
|
||||
self.assertIn(f"mount -t ext4 /dev/vdb {infra_vm._GATEWAY_CA_MOUNT}", init)
|
||||
self.assertLess(init.index("/dev/vdb"),
|
||||
init.index("bot_bottle.gateway.bootstrap"))
|
||||
self.assertNotIn("/dev/vdb", init) # no registry volume on the data plane
|
||||
self.assertIn("export PATH=", init) # shared preamble
|
||||
self.assertIn("BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise", init)
|
||||
self.assertIn(f"cat {infra_vm._GUEST_GATEWAY_JWT_PATH}", init) # host-minted JWT
|
||||
|
||||
@@ -125,6 +125,31 @@ class TestReprovisionGateway(unittest.TestCase):
|
||||
self.c.reprovision_gateway("b1", "key")
|
||||
|
||||
|
||||
class TestUpdateAgentSecret(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.c = OrchestratorClient("http://orch:8080")
|
||||
|
||||
def test_success_posts_single_secret(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp(200, {"updated": True})) as opened:
|
||||
self.assertTrue(self.c.update_agent_secret("b1", "A", "fresh", "key"))
|
||||
request = opened.call_args.args[0]
|
||||
self.assertEqual("POST", request.get_method())
|
||||
self.assertTrue(request.full_url.endswith("/bottles/b1/secret"))
|
||||
self.assertEqual(
|
||||
{"name": "A", "value": "fresh", "env_var_secret": "key"},
|
||||
json.loads(request.data),
|
||||
)
|
||||
|
||||
def test_unknown_bottle_is_false(self) -> None:
|
||||
with patch(_URLOPEN, side_effect=_http_error(404)):
|
||||
self.assertFalse(self.c.update_agent_secret("b1", "A", "v", "key"))
|
||||
|
||||
def test_other_status_raises(self) -> None:
|
||||
with patch(_URLOPEN, side_effect=_http_error(400)):
|
||||
with self.assertRaises(OrchestratorClientError):
|
||||
self.c.update_agent_secret("b1", "A", "v", "key")
|
||||
|
||||
|
||||
class TestHealthAndPolicy(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.c = OrchestratorClient("http://orch:8080")
|
||||
|
||||
@@ -199,6 +199,20 @@ class TestAgentSecrets(unittest.TestCase):
|
||||
got = self.store.get_agent_secrets("bottle-1")
|
||||
self.assertEqual({"K": "new", "K2": "v2"}, got)
|
||||
|
||||
def test_store_agent_secret_upserts_one_key_leaving_others(self) -> None:
|
||||
self.store.store_agent_secrets("bottle-1", {"K": "v1", "K2": "v2"})
|
||||
self.store.store_agent_secret("bottle-1", "K", "v1-new") # update existing
|
||||
self.store.store_agent_secret("bottle-1", "K3", "v3") # insert new
|
||||
self.assertEqual(
|
||||
{"K": "v1-new", "K2": "v2", "K3": "v3"},
|
||||
self.store.get_agent_secrets("bottle-1"),
|
||||
)
|
||||
|
||||
def test_store_agent_secret_does_not_duplicate_rows(self) -> None:
|
||||
self.store.store_agent_secret("bottle-1", "K", "v1")
|
||||
self.store.store_agent_secret("bottle-1", "K", "v2")
|
||||
self.assertEqual({"K": "v2"}, self.store.get_agent_secrets("bottle-1"))
|
||||
|
||||
def test_delete_removes_secrets(self) -> None:
|
||||
self.store.store_agent_secrets("bottle-1", {"K": "v"})
|
||||
self.store.delete_agent_secrets("bottle-1")
|
||||
|
||||
@@ -87,6 +87,47 @@ class TestDispatch(unittest.TestCase):
|
||||
{"EGRESS_TOKEN_0": "upstream-secret"}, self.orch.tokens_for(bottle_id),
|
||||
)
|
||||
|
||||
def test_update_agent_secret_in_place(self) -> None:
|
||||
key = base64.urlsafe_b64encode(b"unit-test-key").rstrip(b"=").decode()
|
||||
status, payload = dispatch(
|
||||
self.orch, "POST", "/bottles", _body({
|
||||
"source_ip": "10.243.0.21",
|
||||
"tokens": {"A": "old", "B": "keep"},
|
||||
"env_var_secret": key,
|
||||
}),
|
||||
)
|
||||
self.assertEqual(201, status)
|
||||
bottle_id = payload["bottle_id"]
|
||||
assert isinstance(bottle_id, str)
|
||||
status, response = dispatch(
|
||||
self.orch, "POST", f"/bottles/{bottle_id}/secret",
|
||||
_body({"name": "A", "value": "fresh", "env_var_secret": key}),
|
||||
)
|
||||
self.assertEqual((200, {"updated": True}), (status, response))
|
||||
self.assertEqual({"A": "fresh", "B": "keep"}, self.orch.tokens_for(bottle_id))
|
||||
|
||||
def test_update_agent_secret_validates_and_unknown_bottle(self) -> None:
|
||||
status, _ = dispatch(self.orch, "POST", "/bottles/b1/secret", b"not-json")
|
||||
self.assertEqual(400, status)
|
||||
status, _ = dispatch(
|
||||
self.orch, "POST", "/bottles/b1/secret", _body({"name": "A"}),
|
||||
)
|
||||
self.assertEqual(400, status) # value + env_var_secret missing
|
||||
status, _ = dispatch(
|
||||
self.orch, "POST", "/bottles/ghost/secret",
|
||||
_body({"name": "A", "value": "v", "env_var_secret": "k"}),
|
||||
)
|
||||
self.assertEqual(404, status)
|
||||
|
||||
def test_update_agent_secret_is_cli_only(self) -> None:
|
||||
# A data-plane (`gateway`) caller must not set a bottle's tokens.
|
||||
status, _ = dispatch(
|
||||
self.orch, "POST", "/bottles/b1/secret",
|
||||
_body({"name": "A", "value": "v", "env_var_secret": "k"}),
|
||||
role=ROLE_GATEWAY,
|
||||
)
|
||||
self.assertEqual(403, status)
|
||||
|
||||
def test_reprovision_validates_request_and_missing_rows(self) -> None:
|
||||
status, _ = dispatch(
|
||||
self.orch, "POST", "/bottles/b1/reprovision_gateway", b"not-json",
|
||||
|
||||
@@ -103,6 +103,25 @@ class TestOrchestrator(unittest.TestCase):
|
||||
self.assertTrue(self.orch.reprovision_from_secret(rec.bottle_id, key))
|
||||
self.assertEqual({"EGRESS_TOKEN_0": "secret"}, self.orch.tokens_for(rec.bottle_id))
|
||||
|
||||
def test_update_agent_secret_refreshes_one_token_in_place(self) -> None:
|
||||
key = new_env_var_secret()
|
||||
rec = self.orch.launch_bottle(
|
||||
"10.243.0.20", tokens={"A": "old-a", "B": "keep-b"}, env_var_secret=key,
|
||||
)
|
||||
self.assertTrue(self.orch.update_agent_secret(rec.bottle_id, "A", "new-a", key))
|
||||
# In memory: A refreshed, B left untouched.
|
||||
self.assertEqual({"A": "new-a", "B": "keep-b"}, self.orch.tokens_for(rec.bottle_id))
|
||||
# At rest: the whole set stays decryptable with the SAME env_var_secret,
|
||||
# so a later reprovision restores the refreshed value (not the launch one).
|
||||
self.orch._tokens.clear()
|
||||
self.assertTrue(self.orch.reprovision_from_secret(rec.bottle_id, key))
|
||||
self.assertEqual({"A": "new-a", "B": "keep-b"}, self.orch.tokens_for(rec.bottle_id))
|
||||
|
||||
def test_update_agent_secret_unknown_bottle_is_false(self) -> None:
|
||||
self.assertFalse(
|
||||
self.orch.update_agent_secret("ghost", "A", "v", new_env_var_secret())
|
||||
)
|
||||
|
||||
def test_reprovision_rejects_missing_rows_and_wrong_key(self) -> None:
|
||||
self.assertFalse(self.orch.reprovision_from_secret("missing", new_env_var_secret()))
|
||||
key = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"
|
||||
|
||||
Reference in New Issue
Block a user