dee0121e8d
prd-number-check / require-numbered-prds (pull_request) Successful in 13s
tracker-policy-pr / check-pr (pull_request) Successful in 7s
test / image-input-builds (pull_request) Successful in 41s
test / unit (pull_request) Successful in 51s
test / integration-docker (pull_request) Successful in 58s
test / coverage (pull_request) Successful in 41s
test / image-input-builds (push) Successful in 48s
test / unit (push) Successful in 56s
lint / lint (push) Successful in 1m2s
Update Quality Badges / update-badges (push) Successful in 1m4s
test / integration-docker (push) Failing after 2m53s
test / coverage (push) Has been skipped
On a Firecracker gateway cold boot, reconcile every live agent VM against the fresh gateway: push the new mitmproxy CA into each agent's trust store, re-provision git-gate repos/creds from the persisted upstreams snapshot, and restore egress tokens. Per-bottle failures are logged and skipped so one unreachable VM does not block the rest. New modules / changes: - backend/firecracker/reconcile.py: attach_bottled_agents_to_gateway, _push_ca, _reprovision_git_gate, _guest_ip_from_config - git_gate/provision.py: write upstreams.json after key provisioning so the bring-up reconcile can reconstruct the upstream table without the manifest - backend/firecracker/infra.py: call attach_bottled_agents_to_gateway in the cold-boot branch of ensure_running() - backend/base.py: no-op default on BottleBackend - backend/firecracker/consolidated_launch.py: remove superseded _reprovision_running_bottles / _guest_ip_from_config - orchestrator: OrchestratorCore.update_agent_secret + POST /bottles/<id>/secret + client.update_agent_secret (single-secret in-place update, the reusable primitive from the 2026-07-26 hotfix) - tests: 15 new tests in test_firecracker_reconcile.py; updated test_firecracker_infra.py cold-boot cases; stale FC reprovision tests removed from test_backend_secret_reprovision.py Closes #516.
176 lines
6.3 KiB
Python
176 lines
6.3 KiB
Python
"""git-gate deploy-key lifecycle for `gitea` upstreams (PRD 0047/0048).
|
|
|
|
Provisions a fresh ed25519 deploy key via the forge API at prepare time
|
|
and revokes it at teardown, so the agent never holds an upstream
|
|
credential. Split out of `git_gate.py`; the forge HTTP client is lazily
|
|
imported (`deploy_key_provisioner`) to keep its cost off the host path.
|
|
`git_gate` re-exports these names for API stability."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import dataclasses
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from ..bottle_state import globalize_slug
|
|
from ..errors import MissingEnvVarError
|
|
from ..log import info
|
|
from ..manifest import ManifestBottle, ManifestGitEntry
|
|
from ..gateway.git_gate.render import GitGateUpstream
|
|
|
|
if TYPE_CHECKING:
|
|
from .plan import GitGatePlan
|
|
|
|
def _provision_dynamic_key(
|
|
entry: ManifestGitEntry,
|
|
slug: str,
|
|
stage_dir: Path,
|
|
) -> str:
|
|
"""Generate a fresh ed25519 keypair, register the public half with
|
|
the forge, and persist the private key + key ID under `stage_dir`.
|
|
|
|
Returns the host-side path to the private key file so the caller
|
|
can inject it into the GitGateUpstream as `identity_file`."""
|
|
from ..deploy_key_provisioner import get_provisioner
|
|
pk = entry.Key
|
|
token = os.environ.get(pk.forge_token_env)
|
|
if token is None:
|
|
raise MissingEnvVarError(
|
|
pk.forge_token_env,
|
|
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
|
|
f" = {pk.forge_token_env!r}: env var is not set",
|
|
)
|
|
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
|
provisioner = get_provisioner(pk.provider, token, api_url)
|
|
|
|
owner_repo = entry.UpstreamPath
|
|
if owner_repo.endswith(".git"):
|
|
owner_repo = owner_repo[:-4]
|
|
title = f"bot-bottle:{globalize_slug(slug)}:{entry.Name}"
|
|
|
|
info(f"provisioning deploy key for git-gate.repos[{entry.Name!r}]")
|
|
key_id, private_key_bytes = provisioner.create(owner_repo, title)
|
|
|
|
key_file = stage_dir / f"{entry.Name}-key"
|
|
key_file.write_bytes(private_key_bytes)
|
|
key_file.chmod(0o600)
|
|
|
|
id_file = stage_dir / f"{entry.Name}-deploy-key-id"
|
|
id_file.write_text(key_id)
|
|
id_file.chmod(0o600)
|
|
|
|
info(f"provisioned deploy key {key_id} for git-gate.repos[{entry.Name!r}]")
|
|
return str(key_file)
|
|
|
|
|
|
def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) -> None:
|
|
"""Revoke all deploy keys provisioned for `bottle` during prepare.
|
|
|
|
Called at teardown after containers stop. Raises if any revocation
|
|
fails — a stranded key is a security concern that the operator must
|
|
address manually."""
|
|
from ..deploy_key_provisioner import get_provisioner
|
|
for entry in bottle.git:
|
|
if entry.Key.provider != "gitea":
|
|
continue
|
|
pk = entry.Key
|
|
id_file = stage_dir / f"{entry.Name}-deploy-key-id"
|
|
if not id_file.exists():
|
|
continue
|
|
key_id = id_file.read_text().strip()
|
|
token = os.environ.get(pk.forge_token_env)
|
|
if token is None:
|
|
raise MissingEnvVarError(
|
|
pk.forge_token_env,
|
|
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
|
|
f" = {pk.forge_token_env!r}: env var is not set;"
|
|
f" cannot revoke deploy key {key_id}",
|
|
)
|
|
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
|
provisioner = get_provisioner(pk.provider, token, api_url)
|
|
owner_repo = entry.UpstreamPath
|
|
if owner_repo.endswith(".git"):
|
|
owner_repo = owner_repo[:-4]
|
|
info(f"revoking deploy key {key_id} for git-gate.repos[{entry.Name!r}]")
|
|
provisioner.delete(owner_repo, key_id)
|
|
info(f"revoked deploy key {key_id} for git-gate.repos[{entry.Name!r}]")
|
|
|
|
|
|
def _resolve_identity_file(entry: ManifestGitEntry, slug: str, stage_dir: Path) -> str:
|
|
"""Return the host-side SSH identity file path for this entry.
|
|
For gitea entries, provisions a fresh deploy key first."""
|
|
if entry.Key.provider == "gitea":
|
|
return _provision_dynamic_key(entry, slug, stage_dir)
|
|
return entry.IdentityFile
|
|
|
|
|
|
def provision_git_gate_dynamic_keys(
|
|
bottle: ManifestBottle,
|
|
plan: "GitGatePlan",
|
|
stage_dir: Path,
|
|
) -> "GitGatePlan":
|
|
"""Provision dynamic git-gate keys and return an updated plan.
|
|
|
|
This runs during backend launch, after the operator confirms the
|
|
preflight. Plan preparation intentionally stays side-effect-light:
|
|
dry-runs and aborted launches must not create remote deploy keys.
|
|
"""
|
|
if not plan.upstreams:
|
|
return plan
|
|
|
|
upstreams_by_name: dict[str, GitGateUpstream] = {
|
|
upstream.name: upstream for upstream in plan.upstreams
|
|
}
|
|
updated: list[GitGateUpstream] = []
|
|
for entry in bottle.git:
|
|
upstream = upstreams_by_name.get(entry.Name)
|
|
if upstream is None:
|
|
continue
|
|
if entry.Key.provider == "gitea":
|
|
identity_file = _provision_dynamic_key(entry, plan.slug, stage_dir)
|
|
upstream = dataclasses.replace(upstream, identity_file=identity_file)
|
|
updated.append(upstream)
|
|
|
|
if len(updated) != len(plan.upstreams):
|
|
updated_names = {u.name for u in updated}
|
|
for upstream in plan.upstreams:
|
|
if upstream.name not in updated_names:
|
|
updated.append(upstream)
|
|
|
|
final_plan = dataclasses.replace(plan, upstreams=tuple(updated))
|
|
_write_upstreams_snapshot(stage_dir, final_plan.upstreams)
|
|
return final_plan
|
|
|
|
|
|
def _write_upstreams_snapshot(
|
|
stage_dir: Path, upstreams: tuple[GitGateUpstream, ...]
|
|
) -> None:
|
|
"""Persist the fully-resolved upstream table to `upstreams.json` in
|
|
`stage_dir` so the bring-up reconcile can re-provision git-gate without
|
|
the manifest. Written after dynamic keys are provisioned so every
|
|
identity_file is set."""
|
|
data = [
|
|
{
|
|
"name": u.name,
|
|
"upstream_url": u.upstream_url,
|
|
"upstream_host": u.upstream_host,
|
|
"upstream_port": u.upstream_port,
|
|
"identity_file": u.identity_file,
|
|
"known_host_key": u.known_host_key,
|
|
}
|
|
for u in upstreams
|
|
]
|
|
snapshot = stage_dir / "upstreams.json"
|
|
snapshot.write_text(json.dumps(data, indent=2))
|
|
snapshot.chmod(0o600)
|
|
|
|
|
|
__all__ = [
|
|
"revoke_git_gate_provisioned_keys",
|
|
"provision_git_gate_dynamic_keys",
|
|
"_provision_dynamic_key",
|
|
"_resolve_identity_file",
|
|
]
|