diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index 03ab619..fe49903 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -37,7 +37,10 @@ from pathlib import Path from typing import Callable, Generator from ...egress import egress_resolve_token_values -from ...git_gate import revoke_git_gate_provisioned_keys +from ...git_gate import ( + provision_git_gate_dynamic_keys, + revoke_git_gate_provisioned_keys, +) from ...log import info, warn from . import network as network_mod from . import util as docker_mod @@ -118,6 +121,11 @@ def launch( git_gate_plan = plan.git_gate_plan if git_gate_plan.upstreams: + git_gate_plan = provision_git_gate_dynamic_keys( + plan.manifest.bottle, + git_gate_plan, + git_gate_state_dir(plan.slug), + ) git_gate_plan = dataclasses.replace( git_gate_plan, internal_network=internal_network, diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index d4e75fb..e0fe254 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -28,7 +28,10 @@ from ...egress import ( egress_resolve_token_values, egress_sidecar_env_entries, ) -from ...git_gate import revoke_git_gate_provisioned_keys +from ...git_gate import ( + provision_git_gate_dynamic_keys, + revoke_git_gate_provisioned_keys, +) from ...log import die, info, warn from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT from ...util import expand_tilde @@ -98,6 +101,8 @@ def launch( egress_network = egress_network_name(plan.slug) _create_networks(internal_network, egress_network, stack) + plan = _provision_git_gate_keys(plan) + sidecar_name = sidecar_container_name(plan.slug) container_mod.force_remove_container(sidecar_name) _start_sidecar_bundle(plan, sidecar_name, internal_network, egress_network) @@ -241,6 +246,19 @@ def _stamp_agent_urls( ) +def _provision_git_gate_keys( + plan: MacosContainerBottlePlan, +) -> MacosContainerBottlePlan: + if not plan.git_gate_plan.upstreams: + return plan + git_gate_plan = provision_git_gate_dynamic_keys( + plan.manifest.bottle, + plan.git_gate_plan, + git_gate_state_dir(plan.slug), + ) + return dataclasses.replace(plan, git_gate_plan=git_gate_plan) + + def _stage_git_gate(plan: MacosContainerBottlePlan, sidecar_name: str) -> None: gp = plan.git_gate_plan if not gp.upstreams: diff --git a/bot_bottle/backend/smolmachines/launch.py b/bot_bottle/backend/smolmachines/launch.py index 8b67e25..483cf85 100644 --- a/bot_bottle/backend/smolmachines/launch.py +++ b/bot_bottle/backend/smolmachines/launch.py @@ -41,7 +41,10 @@ from ..docker.git_gate import ( GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_HOOK_IN_CONTAINER, ) -from ...git_gate import revoke_git_gate_provisioned_keys +from ...git_gate import ( + provision_git_gate_dynamic_keys, + revoke_git_gate_provisioned_keys, +) from ...log import info, warn from ...bottle_state import ( egress_state_dir, @@ -174,6 +177,7 @@ def _start_bundle( ) -> SmolmachinesBottlePlan: """Build the BundleLaunchSpec, resolve token env, start the sidecar bundle container, and register teardown.""" + plan = _provision_git_gate_keys(plan) bundle_spec = _bundle_launch_spec(plan, network, loopback_ip) token_env = _resolve_token_env(plan, dict(os.environ)) _bundle.ensure_bundle_image(bundle_spec.image) @@ -182,6 +186,19 @@ def _start_bundle( return plan +def _provision_git_gate_keys( + plan: SmolmachinesBottlePlan, +) -> SmolmachinesBottlePlan: + if not plan.git_gate_plan.upstreams: + return plan + git_gate_plan = provision_git_gate_dynamic_keys( + plan.manifest.bottle, + plan.git_gate_plan, + git_gate_state_dir(plan.slug), + ) + return dataclasses.replace(plan, git_gate_plan=git_gate_plan) + + def _discover_urls( plan: SmolmachinesBottlePlan, loopback_ip: str, diff --git a/bot_bottle/git_gate.py b/bot_bottle/git_gate.py index c403df7..e1deae1 100644 --- a/bot_bottle/git_gate.py +++ b/bot_bottle/git_gate.py @@ -30,7 +30,6 @@ backend-specific and lives on concrete subclasses (see from __future__ import annotations -import dataclasses from abc import ABC from dataclasses import dataclass from pathlib import Path @@ -53,6 +52,7 @@ from .git_gate_render import ( _gitconfig_validate_value, ) from .git_gate_provision import ( + provision_git_gate_dynamic_keys, revoke_git_gate_provisioned_keys, _provision_dynamic_key, _resolve_identity_file, @@ -93,20 +93,14 @@ class GitGate(ABC): entrypoint, pre-receive hook, and access-hook scripts (mode 600) under `stage_dir`. Pure host-side, no docker subprocess. - For `gitea` key entries, also generates and registers - a fresh deploy key via the forge API and writes the private key - + key ID to `stage_dir`. + For `gitea` key entries, the returned upstream intentionally + has an empty identity file. Backend launch fills that in after + the operator confirms the preflight. Returned plan is incomplete: the launch step must fill `internal_network` / `egress_network` via `dataclasses.replace` before passing the plan to `.start`.""" - upstreams_list = list(git_gate_upstreams_for_bottle(bottle)) - for i, entry in enumerate(bottle.git): - upstreams_list[i] = dataclasses.replace( - upstreams_list[i], - identity_file=_resolve_identity_file(entry, slug, stage_dir), - ) - upstreams = tuple(upstreams_list) + upstreams = git_gate_upstreams_for_bottle(bottle) entrypoint = stage_dir / "git_gate_entrypoint.sh" entrypoint.write_text(git_gate_render_entrypoint(upstreams)) entrypoint.chmod(0o600) @@ -162,6 +156,7 @@ __all__ = [ "git_gate_render_entrypoint", "git_gate_render_hook", "git_gate_render_access_hook", + "provision_git_gate_dynamic_keys", "revoke_git_gate_provisioned_keys", "_gitconfig_validate_value", "_provision_dynamic_key", diff --git a/bot_bottle/git_gate_provision.py b/bot_bottle/git_gate_provision.py index 3b7e26a..e3f25e1 100644 --- a/bot_bottle/git_gate_provision.py +++ b/bot_bottle/git_gate_provision.py @@ -9,10 +9,16 @@ imported (`deploy_key_provisioner`) to keep its cost off the host path. from __future__ import annotations import os +import dataclasses from pathlib import Path +from typing import TYPE_CHECKING from .log import info from .manifest import ManifestBottle, ManifestGitEntry +from .git_gate_render import GitGateUpstream + +if TYPE_CHECKING: + from .git_gate import GitGatePlan def _provision_dynamic_key( entry: ManifestGitEntry, @@ -95,8 +101,45 @@ def _resolve_identity_file(entry: ManifestGitEntry, slug: str, stage_dir: Path) 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) + + return dataclasses.replace(plan, upstreams=tuple(updated)) + + __all__ = [ "revoke_git_gate_provisioned_keys", + "provision_git_gate_dynamic_keys", "_provision_dynamic_key", "_resolve_identity_file", ] diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index fbe21f5..be6fc32 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -14,6 +14,7 @@ from bot_bottle.git_gate import ( git_gate_render_access_hook, git_gate_render_entrypoint, git_gate_render_hook, + provision_git_gate_dynamic_keys, revoke_git_gate_provisioned_keys, _resolve_identity_file, git_gate_upstreams_for_bottle, @@ -371,6 +372,27 @@ class TestDynamicKeyProvisioning(unittest.TestCase): self.assertEqual("/tmp/provisioned-key", _resolve_identity_file(entry, "demo", self.stage)) mock_provision.assert_called_once() + def test_prepare_defers_gitea_key_provisioning(self): + bottle = self._gitea_manifest().bottles["dev"] + with patch("bot_bottle.git_gate_provision._provision_dynamic_key") as mock_provision: + plan = _StubGate().prepare(bottle, "demo", self.stage) + + mock_provision.assert_not_called() + self.assertEqual("", plan.upstreams[0].identity_file) + + def test_launch_time_helper_provisions_gitea_keys(self): + bottle = self._gitea_manifest().bottles["dev"] + plan = _StubGate().prepare(bottle, "demo", self.stage) + + with patch( + "bot_bottle.git_gate_provision._provision_dynamic_key", + return_value="/tmp/provisioned-key", + ) as mock_provision: + updated = provision_git_gate_dynamic_keys(bottle, plan, self.stage) + + mock_provision.assert_called_once_with(bottle.git[0], "demo", self.stage) + self.assertEqual("/tmp/provisioned-key", updated.upstreams[0].identity_file) + def test_revoke_skips_non_gitea_and_missing_id_file(self): revoke_git_gate_provisioned_keys(fixture_with_git().bottles["dev"], self.stage)