feat(orchestrator): update a single egress secret in place (0081)

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>
This commit is contained in:
2026-07-26 18:21:40 -04:00
committed by claude
parent cecc1b4d60
commit cca724244e
8 changed files with 189 additions and 0 deletions
+25
View File
@@ -151,6 +151,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")
+41
View File
@@ -125,6 +125,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"