Compare commits

..

3 Commits

Author SHA1 Message Date
didericis 4434752db4 docs(prd): reconcile via a backend-ABC method across all backends (0081 review)
prd-number-check / require-numbered-prds (pull_request) Successful in 11s
test / image-input-builds (pull_request) Has started running
test / integration-docker (pull_request) Failing after 1m13s
test / unit (pull_request) Successful in 1m2s
test / coverage (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 10s
Address review: target all backends, not firecracker-only. Make the reconcile
an @abc.abstractmethod `attach_bottled_agents_to_gateway()` on BottleBackend so
every backend implements it (cross-backend by construction) and the host calls
it on gateway bring-up. Drop the docker/macOS non-goal; add the open question of
retiring docker's now-redundant CA bind-mount.

Refs #516
2026-07-26 19:06:00 -04:00
didericis f9d0e15f13 feat(orchestrator): update a single egress secret in place (0081)
prd-number-check / require-numbered-prds (pull_request) Successful in 8s
tracker-policy-pr / check-pr (pull_request) Successful in 22s
test / unit (pull_request) Successful in 58s
lint / lint (push) Successful in 1m7s
test / integration-docker (pull_request) Successful in 1m12s
test / coverage (pull_request) Successful in 46s
Add a single-secret counterpart to reprovision_from_secret's restore-all:
update ONE egress token for a running bottle without a relaunch, for
refreshing a short-lived host credential (e.g. the Codex access token)
whose launch-time snapshot has expired.

- registry_store.store_agent_secret: per-key upsert (delete+insert of the
  one row), the counterpart of store_agent_secrets' replace-all.
- OrchestratorCore.update_agent_secret: set the in-memory token AND upsert
  the re-encrypted row under the bottle's env_var_secret, leaving other
  tokens untouched.
- POST /bottles/<id>/secret (cli-only) + client.update_agent_secret.

Refs #510, #512

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 18:52:11 -04:00
didericis bf6b32381d docs(prd): reprovision gateway-dependent state on gateway bring-up (0081)
Reconcile every running bottle against a freshly-booted gateway (replace
each agent's CA, re-provision git-gate, restore egress tokens) instead of
persisting gateway state on volumes. Supersedes the abandoned CA-volume
(#511) and git-gate-volume (#513) PRs.

Refs #510, #512

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 18:12:03 -04:00
22 changed files with 339 additions and 298 deletions
+1 -5
View File
@@ -100,8 +100,6 @@ jobs:
export BOT_BOTTLE_DOCKER_CLIENT_NETWORK="$DOCKER_CLIENT_NETWORK"
export BOT_BOTTLE_DOCKER_ROOT_MOUNT="bot-bottle-ci-root-$RUN_KEY"
export BOT_BOTTLE_DOCKER_CA_MOUNT="bot-bottle-ci-ca-$RUN_KEY"
export BOT_BOTTLE_DOCKER_GIT_MOUNT="bot-bottle-ci-git-$RUN_KEY"
export BOT_BOTTLE_DOCKER_CREDS_MOUNT="bot-bottle-ci-creds-$RUN_KEY"
python3 -m coverage run -m scripts.unittest_gate \
-t . -s tests/integration -v \
--minimum-executed 22 --fail-on-skip
@@ -112,9 +110,7 @@ jobs:
RUN_KEY="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-0}}"
docker volume rm --force \
"bot-bottle-ci-root-$RUN_KEY" \
"bot-bottle-ci-ca-$RUN_KEY" \
"bot-bottle-ci-git-$RUN_KEY" \
"bot-bottle-ci-creds-$RUN_KEY" 2>/dev/null || true
"bot-bottle-ci-ca-$RUN_KEY" 2>/dev/null || true
# Non-dot name so upload-artifact's dotfile-skipping glob picks it up.
- name: Stage docker coverage for upload
+1 -5
View File
@@ -116,8 +116,6 @@ jobs:
export BOT_BOTTLE_DOCKER_CLIENT_NETWORK="$DOCKER_CLIENT_NETWORK"
export BOT_BOTTLE_DOCKER_ROOT_MOUNT="bot-bottle-ci-root-$RUN_KEY"
export BOT_BOTTLE_DOCKER_CA_MOUNT="bot-bottle-ci-ca-$RUN_KEY"
export BOT_BOTTLE_DOCKER_GIT_MOUNT="bot-bottle-ci-git-$RUN_KEY"
export BOT_BOTTLE_DOCKER_CREDS_MOUNT="bot-bottle-ci-creds-$RUN_KEY"
python3 -m coverage run -m scripts.unittest_gate \
-t . -s tests/integration -v \
--minimum-executed 22 --fail-on-skip
@@ -128,9 +126,7 @@ jobs:
RUN_KEY="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-0}}"
docker volume rm --force \
"bot-bottle-ci-root-$RUN_KEY" \
"bot-bottle-ci-ca-$RUN_KEY" \
"bot-bottle-ci-git-$RUN_KEY" \
"bot-bottle-ci-creds-$RUN_KEY" 2>/dev/null || true
"bot-bottle-ci-ca-$RUN_KEY" 2>/dev/null || true
- name: Stage docker coverage for upload
run: cp .coverage.docker coverage-docker.dat
-21
View File
@@ -9,8 +9,6 @@ from .gateway_transport import DockerGatewayTransport
from ...paths import (
ORCHESTRATOR_AUTH_JWT_ENV,
host_gateway_ca_dir,
host_gateway_git_dir,
host_gateway_creds_dir,
)
from ... import resources
from ...gateway import (
@@ -42,8 +40,6 @@ class DockerGateway(Gateway):
dockerfile: str | None = GATEWAY_DOCKERFILE,
host_port_bindings: tuple[int, ...] = (),
ca_mount_source: str | Path | None = None,
git_mount_source: str | Path | None = None,
creds_mount_source: str | Path | None = None,
subnet: str | None = None,
) -> None:
self.image_ref = image_ref
@@ -78,17 +74,6 @@ class DockerGateway(Gateway):
self._ca_mount_source = str(
ca_mount_source or configured_ca or host_gateway_ca_dir()
)
# The persistent git-gate mounts (/git bare repos, /git-gate/creds deploy
# creds) — same host-bind-mount rationale as the CA (issue #512). The env
# overrides let CI point them at per-run named volumes it cleans up.
configured_git = os.environ.get("BOT_BOTTLE_DOCKER_GIT_MOUNT", "").strip()
self._git_mount_source = str(
git_mount_source or configured_git or host_gateway_git_dir()
)
configured_creds = os.environ.get("BOT_BOTTLE_DOCKER_CREDS_MOUNT", "").strip()
self._creds_mount_source = str(
creds_mount_source or configured_creds or host_gateway_creds_dir()
)
def image_exists(self) -> bool:
return run_docker(["docker", "image", "inspect", self.image_ref]).returncode == 0
@@ -213,12 +198,6 @@ class DockerGateway(Gateway):
# container recreation AND docker volume pruning (agents trust it)
# — see host_gateway_ca_dir / issue #450.
"--volume", f"{self._ca_mount_source}:{MITMPROXY_HOME}",
# Persist per-bottle git-gate state (bare repos + deploy creds) on
# the host so a gateway restart doesn't drop already-running bottles'
# repos — they would otherwise 404 on fetch/push (issue #512). Same
# host-bind-mount rationale as the CA.
"--volume", f"{self._git_mount_source}:/git",
"--volume", f"{self._creds_mount_source}:/git-gate/creds",
# No DB mount: the data plane (egress / supervise / git-gate) reaches
# the supervise queue over the control-plane RPC and never opens
# bot-bottle.db, so the gateway container gets no file handle on it
@@ -84,7 +84,7 @@ def _config(
vcpus: int,
mem_mib: int,
guest_mac: str,
data_drives: tuple[Path, ...] = (),
data_drive: Path | None = None,
extra_boot_args: str = "",
) -> dict[str, object]:
drives: list[dict[str, object]] = [
@@ -95,15 +95,12 @@ def _config(
"is_read_only": False,
}
]
# Extra virtio-block devices — the infra VMs' persistent "volumes",
# host-side ext4 files that outlive the ephemeral rootfs across VM restarts.
# They appear to the guest as /dev/vdb, /dev/vdc, ... in list order (after
# the root /dev/vda), so callers must keep the order stable: the orchestrator
# attaches its registry (vdb); the gateway attaches its CA (vdb) then its
# git-gate state (vdc).
for i, data_drive in enumerate(data_drives):
# A second virtio-block device (guest /dev/vdb) — the infra VM's
# persistent registry "volume", a host-side ext4 file that outlives the
# ephemeral rootfs across VM restarts.
if data_drive is not None:
drives.append({
"drive_id": f"data{i}",
"drive_id": "data",
"path_on_host": str(data_drive),
"is_root_device": False,
"is_read_only": False,
@@ -141,7 +138,7 @@ def boot(
mem_mib: int = 2048,
guest_mac: str = "06:00:AC:10:00:02",
detached: bool = False,
data_drives: tuple[Path, ...] = (),
data_drive: Path | None = None,
extra_boot_args: str = "",
) -> VmHandle:
"""Write the config and launch the VMM. Returns once the process is
@@ -158,7 +155,7 @@ def boot(
_config(
rootfs=rootfs, tap=tap, guest_ip=guest_ip, host_ip=host_ip,
pubkey=pubkey, vcpus=vcpus, mem_mib=mem_mib, guest_mac=guest_mac,
data_drives=data_drives, extra_boot_args=extra_boot_args,
data_drive=data_drive, extra_boot_args=extra_boot_args,
),
indent=2,
))
+1 -70
View File
@@ -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,26 +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"
# The gateway VM's persistent git-gate volume — a (sparse) ext4 file the gateway
# init mounts, then bind-mounts onto /git + /git-gate/creds (see
# `infra_vm._GATEWAY_GIT_MOUNT`), so per-bottle bare repos and deploy creds
# SURVIVE a gateway-VM rebuild. Without it a restart drops every already-running
# bottle's git-gate state and its agent 404s on fetch/push (same class as the CA
# — issue #512). Bare repos hold upstream history, so this is sized far larger
# than the CA volume; mke2fs leaves the file sparse, so the host only stores
# blocks actually used.
_GIT_VOLUME_SIZE = "8G"
_CA_FETCH_TIMEOUT_SECONDS = 15.0
@@ -116,18 +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. Two persistent
# volumes ride along, attached in a FIXED order the gateway init depends
# on: the CA volume as /dev/vdb (mounted at mitmproxy's confdir) and the
# git-gate volume as /dev/vdc (bind-mounted onto /git + /git-gate/creds).
# Both keep gateway-side state STABLE across rebuilds so already-running
# bottles keep working — TLS interception (issue #450) and git-gate fetch
# /push (issue #512) respectively.
# 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_drives=(self._ensure_ca_volume(), self._ensure_git_volume()),
)
infra_vm.push_secret(
vm, self._gateway_token, _GUEST_GATEWAY_JWT_PATH,
@@ -145,46 +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 _ensure_git_volume(self) -> Path:
"""Create the empty ext4 git-gate volume on first use; reuse it after.
Empty on first boot (the gateway init lays out `git/` + `creds/` subdirs
and bind-mounts them); every later boot reuses whatever repos + creds the
volume already holds — which is what keeps git-gate state stable across a
gateway-VM rebuild (issue #512). Sibling of `_ensure_ca_volume`."""
vol = infra_vm._gw_dir() / "gateway-git.ext4"
if vol.exists():
return vol
info(f"creating gateway git-gate volume {vol} ({_GIT_VOLUME_SIZE})")
proc = subprocess.run(
["mke2fs", "-q", "-t", "ext4", "-F", str(vol), _GIT_VOLUME_SIZE],
capture_output=True, text=True, check=False,
)
if proc.returncode != 0:
vol.unlink(missing_ok=True)
die(f"creating gateway git-gate 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."""
+6 -40
View File
@@ -53,25 +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 gateway VM's persistent git-gate volume staging mount (/dev/vdc). The
# gateway boots with this volume (see `FirecrackerGateway._ensure_git_volume`)
# and the init bind-mounts its `git/` + `creds/` subdirs onto the load-bearing
# `/git` and `/git-gate/creds` paths, so per-bottle bare repos + deploy creds
# survive a gateway-VM rebuild; without it a restart drops every already-running
# bottle's git-gate state and its agent 404s on fetch/push (issue #512).
_GATEWAY_GIT_MOUNT = "/var/lib/bot-bottle-gitgate"
_GATEWAY_GIT_REPO_ROOT = "/git"
_GATEWAY_GIT_CREDS_DIR = "/git-gate/creds"
# The two per-plane rootfs source images. The orchestrator VM boots a control
# plane + buildah rootfs (Dockerfile.orchestrator.fc, FROM orchestrator); the
@@ -207,12 +192,11 @@ def boot_vm(
run_dir: Path,
role: str,
mem_mib: int,
data_drives: tuple[Path, ...] = (),
data_drive: Path | None = None,
extra_boot_args: str = "",
) -> InfraVm:
"""Boot the `role` infra VM from its per-plane rootfs on `slot`'s link.
Records the PID. `data_drives` are attached as /dev/vdb, /dev/vdc, ... in
order, so callers must keep the order stable (see `firecracker_vm._config`)."""
Records the PID."""
if not netpool.tap_present(slot.iface):
die(f"infra link {slot.iface} not present.\n"
f" ./cli.py backend setup --backend=firecracker")
@@ -234,7 +218,7 @@ def boot_vm(
name=name, rootfs=rootfs, tap=slot.iface,
guest_ip=slot.guest_ip, host_ip=slot.host_ip, pubkey=pubkey,
run_dir=run_dir, mem_mib=mem_mib, detached=True,
data_drives=data_drives, extra_boot_args=boot_args,
data_drive=data_drive, extra_boot_args=boot_args,
)
_pid_file(run_dir).write_text(str(vm.process.pid))
return InfraVm(guest_ip=slot.guest_ip, private_key=private_key, vm=vm)
@@ -464,24 +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
# Persistent git-gate volume (/dev/vdc): its git/ + creds/ subdirs are
# bind-mounted onto the load-bearing /git and /git-gate/creds so per-bottle bare
# repos + deploy creds survive a gateway-VM rebuild (issue #512). On first boot
# the volume is empty; the subdirs are created here. Must mount BEFORE the data
# plane (hence git-http) starts, and before any per-bottle provisioning writes.
mkdir -p {_GATEWAY_GIT_MOUNT}
mount -t ext4 /dev/vdc {_GATEWAY_GIT_MOUNT} 2>/dev/null || true
mkdir -p {_GATEWAY_GIT_MOUNT}/git {_GATEWAY_GIT_MOUNT}/creds
mkdir -p {_GATEWAY_GIT_REPO_ROOT} {_GATEWAY_GIT_CREDS_DIR}
mount --bind {_GATEWAY_GIT_MOUNT}/git {_GATEWAY_GIT_REPO_ROOT} 2>/dev/null || true
mount --bind {_GATEWAY_GIT_MOUNT}/creds {_GATEWAY_GIT_CREDS_DIR} 2>/dev/null || true
ORCH=$(sed -n 's/.*bb_orch=\\([^ ]*\\).*/\\1/p' /proc/cmdline)
GW_JWT=""
i=0
@@ -104,7 +104,7 @@ class FirecrackerOrchestrator(Orchestrator):
vm = infra_vm.boot_vm(
name=ORCHESTRATOR_NAME, slot=netpool.orch_slot(),
run_dir=infra_vm._orch_dir(), role="orchestrator", mem_mib=_ORCH_MEM_MIB,
data_drives=(self._ensure_registry_volume(),),
data_drive=self._ensure_registry_volume(),
)
# Push the host-canonical signing key (the init waits for it before
# starting the control plane). It comes through the shared provisioning
+21
View File
@@ -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."""
+29
View File
@@ -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):
+24
View File
@@ -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,
-34
View File
@@ -53,15 +53,6 @@ ORCHESTRATOR_AUTH_JWT_ENV = "BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT"
# the shared gateway's TLS interception, so it must not rotate on restart. See
# host_gateway_ca_dir() for why this is a host bind-mount, not a named volume.
GATEWAY_CA_DIRNAME = "gateway-ca"
# The host directories holding the gateway's persistent git-gate state — the
# per-bottle bare repos (`gateway-git`) and deploy creds (`gateway-creds`).
# Bind-mounted into the gateway container at /git and /git-gate/creds so they
# survive container recreation; without it a gateway restart drops every
# already-running bottle's git-gate state and its agent 404s on fetch/push
# (issue #512). Host bind-mounts (not named volumes) for the same reason as the
# CA dir — see host_gateway_ca_dir().
GATEWAY_GIT_DIRNAME = "gateway-git"
GATEWAY_CREDS_DIRNAME = "gateway-creds"
def bot_bottle_root() -> Path:
@@ -106,27 +97,6 @@ def host_gateway_ca_dir() -> Path:
return ca_dir
def host_gateway_git_dir() -> Path:
"""The directory holding the gateway's persistent per-bottle bare repos,
created if missing. Bind-mounted into the gateway container at /git so the
repos survive container recreation (issue #512). A host bind-mount under the
app-data root, never pruned same rationale as host_gateway_ca_dir()."""
git_dir = bot_bottle_root() / GATEWAY_GIT_DIRNAME
git_dir.mkdir(parents=True, exist_ok=True)
return git_dir
def host_gateway_creds_dir() -> Path:
"""The directory holding the gateway's persistent per-bottle git-gate deploy
creds, created if missing. Bind-mounted into the gateway container at
/git-gate/creds so the creds survive container recreation (issue #512). A
host bind-mount under the app-data root same rationale as
host_gateway_ca_dir()."""
creds_dir = bot_bottle_root() / GATEWAY_CREDS_DIRNAME
creds_dir.mkdir(parents=True, exist_ok=True)
return creds_dir
def host_signing_key(filename: str) -> str:
"""A per-host signing key at `<root>/<filename>`, minted (256-bit, url-safe)
and persisted 0600 on first use, then reused.
@@ -173,14 +143,10 @@ __all__ = [
"ORCHESTRATOR_TOKEN_ENV",
"ORCHESTRATOR_AUTH_JWT_ENV",
"GATEWAY_CA_DIRNAME",
"GATEWAY_GIT_DIRNAME",
"GATEWAY_CREDS_DIRNAME",
"bot_bottle_root",
"host_db_path",
"host_db_dir",
"host_gateway_ca_dir",
"host_gateway_git_dir",
"host_gateway_creds_dir",
"host_signing_key",
"host_orchestrator_token",
]
@@ -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.
+4 -7
View File
@@ -404,19 +404,16 @@ class TestBootArgs(unittest.TestCase):
self.assertEqual("bbfc0", cfg["network-interfaces"][0]["host_dev_name"])
self.assertEqual(1, len(cfg["drives"])) # no data drive by default
def test_config_adds_data_drives_in_order(self):
def test_config_adds_data_drive(self):
cfg = cast(Any, firecracker_vm._config(
rootfs=Path("/run/rootfs.ext4"), tap="bbfc0",
guest_ip="100.64.0.1", host_ip="100.64.0.0", pubkey="k",
vcpus=2, mem_mib=2048, guest_mac="06:00:AC:10:00:02",
data_drives=(Path("/run/ca.ext4"), Path("/run/git.ext4")),
data_drive=Path("/run/registry.ext4"),
))
# rootfs (vda) + two data drives, attached in list order so the guest
# sees them as /dev/vdb, /dev/vdc — an order callers depend on.
self.assertEqual(3, len(cfg["drives"]))
self.assertEqual(2, len(cfg["drives"]))
self.assertFalse(cfg["drives"][1]["is_root_device"])
self.assertEqual("/run/ca.ext4", cfg["drives"][1]["path_on_host"])
self.assertEqual("/run/git.ext4", cfg["drives"][2]["path_on_host"])
self.assertEqual("/run/registry.ext4", cfg["drives"][1]["path_on_host"])
class TestBottleExecClose(unittest.TestCase):
+1 -66
View File
@@ -59,13 +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")
git_vol = Path("/gw/gateway-git.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(FirecrackerGateway, "_ensure_git_volume",
return_value=git_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
@@ -73,71 +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 volumes ride in a FIXED order — CA as /dev/vdb, git-gate
# as /dev/vdc — so both survive a gateway-VM rebuild (issues #450, #512).
self.assertEqual((ca_vol, git_vol), kw.get("data_drives"))
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 TestGatewayGitVolume(unittest.TestCase):
"""The persistent git-gate volume that keeps per-bottle bare repos + creds
stable across a gateway-VM rebuild (issue #512)."""
def test_reuses_existing_volume(self) -> None:
gw = FirecrackerGateway()
with tempfile.TemporaryDirectory() as td:
vol = Path(td) / "gateway-git.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_git_volume()
run.assert_not_called() # no mke2fs when it exists — the repos survive
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_git_volume()
argv = run.call_args.args[0]
self.assertIn("mke2fs", argv)
self.assertEqual(str(Path(td) / "gateway-git.ext4"), out.__fspath__())
self.assertIn(str(out), argv)
class TestFirecrackerGatewaySurface(unittest.TestCase):
def test_is_running_reads_the_gateway_pidfile(self) -> None:
gw = FirecrackerGateway()
+1 -17
View File
@@ -61,23 +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"))
# Persistent git-gate volume (/dev/vdc) bind-mounted onto /git and
# /git-gate/creds so per-bottle repos + creds survive a rebuild (#512),
# also before the data plane (and any provisioning writes).
self.assertIn(f"mount -t ext4 /dev/vdc {infra_vm._GATEWAY_GIT_MOUNT}", init)
self.assertIn(
f"mount --bind {infra_vm._GATEWAY_GIT_MOUNT}/git "
f"{infra_vm._GATEWAY_GIT_REPO_ROOT}", init)
self.assertIn(
f"mount --bind {infra_vm._GATEWAY_GIT_MOUNT}/creds "
f"{infra_vm._GATEWAY_GIT_CREDS_DIR}", init)
self.assertLess(init.index("/dev/vdc"),
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
+1 -1
View File
@@ -52,7 +52,7 @@ class TestEnsureRunning(unittest.TestCase):
kw = boot.call_args.kwargs
self.assertEqual("orchestrator", kw["role"])
self.assertEqual(4096, kw["mem_mib"]) # orchestrator keeps build headroom
self.assertEqual((Path("/reg"),), kw["data_drives"]) # DB volume on the CP
self.assertEqual(Path("/reg"), kw["data_drive"]) # DB volume on the CP
# The host-canonical signing key is pushed to the guest signing-key path.
self.assertEqual("host-key", push.call_args.args[1])
self.assertEqual(infra_vm._GUEST_SIGNING_KEY_PATH, push.call_args.args[2])
+25
View File
@@ -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")
-20
View File
@@ -161,26 +161,6 @@ class TestDockerGateway(unittest.TestCase):
"ci-ca-volume:/home/mitmproxy/.mitmproxy", argv
)
def test_persists_git_gate_state_across_recreation(self) -> None:
# Per-bottle bare repos + deploy creds are bind-mounted from the host so
# a gateway restart doesn't drop already-running bottles' git-gate state
# (issue #512). Named sources here stand in for CI's per-run volumes.
sc = DockerGateway(
"bot-bottle-gateway:latest",
git_mount_source="ci-git-volume",
creds_mount_source="ci-creds-volume",
)
def fake(argv: list[str], **_kw: object) -> Mock:
return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc()
with patch(_RUN_DOCKER, side_effect=fake) as run:
sc.connect_to_orchestrator(_ORCH_URL, _TOKEN)
argv = next(c.args[0] for c in run.call_args_list
if c.args[0][:2] == ["docker", "run"])
self.assertIn("ci-git-volume:/git", argv)
self.assertIn("ci-creds-volume:/git-gate/creds", argv)
def test_connect_injects_the_pre_minted_gateway_token(self) -> None:
# The gateway presents the token the orchestrator handed it — it never
# mints (holds no signing key). The value rides the env (bare `--env
@@ -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")
+41
View File
@@ -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",
+19
View File
@@ -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"