diff --git a/bot_bottle/backend/base.py b/bot_bottle/backend/base.py index bb8a2ab..a58ad4f 100644 --- a/bot_bottle/backend/base.py +++ b/bot_bottle/backend/base.py @@ -309,8 +309,8 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): self._preflight() - from ..git_gate_host_key import preflight_host_keys - manifest = preflight_host_keys( + from ..git_gate import GitGate + manifest = GitGate().preflight_host_keys( manifest, headless=spec.headless, home_md=spec.manifest.home_md, diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index 068b7c4..665fa5e 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -38,10 +38,7 @@ from typing import Callable, Generator from ...agent_provider import runtime_for from ...egress import egress_resolve_token_values -from ...git_gate import ( - provision_git_gate_dynamic_keys, - revoke_git_gate_provisioned_keys, -) +from ...git_gate import GitGate from ...image_cache import check_stale from ...log import die, info, warn from .. import BottleImages @@ -126,7 +123,7 @@ def launch( f"teardown failed for container {plan.container_name}" f" (compose-down): {exc!r}" ) - revoke_git_gate_provisioned_keys( + GitGate().revoke_provisioned_keys( _bottle_for_revoke, _git_gate_dir_for_revoke ) @@ -135,7 +132,7 @@ def launch( # provisioning the bottle's repos into the shared gateway. git_gate_plan = plan.git_gate_plan if git_gate_plan.upstreams: - git_gate_plan = provision_git_gate_dynamic_keys( + git_gate_plan = GitGate().provision_dynamic_keys( plan.manifest.bottle, git_gate_plan, git_gate_state_dir(plan.slug), ) diff --git a/bot_bottle/backend/firecracker/launch.py b/bot_bottle/backend/firecracker/launch.py index 37bfb2d..b5fc00d 100644 --- a/bot_bottle/backend/firecracker/launch.py +++ b/bot_bottle/backend/firecracker/launch.py @@ -42,10 +42,7 @@ from ...egress import ( egress_agent_env_entries, egress_resolve_token_values, ) -from ...git_gate import ( - provision_git_gate_dynamic_keys, - revoke_git_gate_provisioned_keys, -) +from ...git_gate import GitGate from ...image_cache import check_stale_path from ...log import die, info, warn from ...supervisor.types import SUPERVISE_PORT @@ -86,7 +83,7 @@ def launch( except BaseException as exc: # noqa: W0718 - teardown must continue teardown_exc = exc warn(f"firecracker teardown failed: {exc!r}") - revoke_git_gate_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke) + GitGate().revoke_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke) if teardown_exc is not None: raise teardown_exc @@ -97,7 +94,7 @@ def launch( # Step 2: mint the git-gate dynamic (gitea) deploy keys, if any. git_gate_plan = plan.git_gate_plan if git_gate_plan.upstreams: - git_gate_plan = provision_git_gate_dynamic_keys( + git_gate_plan = GitGate().provision_dynamic_keys( plan.manifest.bottle, git_gate_plan, git_gate_state_dir(plan.slug), ) diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index 95f5d61..bb2fc79 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -48,10 +48,7 @@ from ...egress import ( egress_agent_env_entries, egress_resolve_token_values, ) -from ...git_gate import ( - provision_git_gate_dynamic_keys, - revoke_git_gate_provisioned_keys, -) +from ...git_gate import GitGate from ...gateway.git_http_backend import DEFAULT_PORT as _GIT_HTTP_PORT from ...image_cache import check_stale from ...log import die, info, warn @@ -151,7 +148,7 @@ def launch( except BaseException as exc: # noqa: W0718 - teardown must continue teardown_exc = exc warn(f"macos-container teardown failed: {exc!r}") - revoke_git_gate_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke) + GitGate().revoke_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke) if teardown_exc is not None: raise teardown_exc @@ -282,7 +279,7 @@ def _provision_git_gate_keys( ) -> MacosContainerBottlePlan: if not plan.git_gate_plan.upstreams: return plan - git_gate_plan = provision_git_gate_dynamic_keys( + git_gate_plan = GitGate().provision_dynamic_keys( plan.manifest.bottle, plan.git_gate_plan, git_gate_state_dir(plan.slug), diff --git a/bot_bottle/gateway/git_gate_render.py b/bot_bottle/gateway/git_gate_render.py index d6fe825..67f055a 100644 --- a/bot_bottle/gateway/git_gate_render.py +++ b/bot_bottle/gateway/git_gate_render.py @@ -3,9 +3,9 @@ Builds the agent's `.gitconfig` insteadOf rewrites, the known_hosts line, and the entrypoint / pre-receive / access-hook scripts the gateway runs. No docker or forge calls — exposed for tests and reuse across -backends. Split out of `git_gate.py` so the control surface (`GitGate`) -and the deploy-key lifecycle (`git_gate_provision`) each read on their -own; `git_gate` re-exports these names for API stability.""" +backends. The host-side service (`git_gate.GitGate`) and the deploy-key +lifecycle (`git_gate.provision`) each read on their own; `git_gate` +re-exports these names for API stability.""" from __future__ import annotations diff --git a/bot_bottle/git_gate.py b/bot_bottle/git_gate.py deleted file mode 100644 index cfb8213..0000000 --- a/bot_bottle/git_gate.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Per-agent git-gate (PRD 0008). - -A third per-agent daemon that fronts the bottle's declared git -upstreams as a transparent mirror. Each `bottle.git` entry maps to -a bare repo on the gate; `git daemon` serves the bare repos over -`git:///.git`. Two hooks make the mirror bidirectional: - -- **`pre-receive`** (push path) — gitleaks-scans incoming refs and, - on clean, forwards them to the real upstream with the - gate-resident credential. -- **`--access-hook`** (fetch path) — runs `git fetch origin --prune` - against the real upstream before every `upload-pack`, so an - agent fetch returns whatever the upstream has *now*. Fail-closed - if the upstream is unreachable. - -The agent never sees the upstream credential under either path. - -Why a separate daemon (not folded into egress or ssh-gate): the -gate is the only one of the three that holds upstream push -credentials. Mixing it with egress would put push creds in the -same blast radius as internet-facing TLS interception; mixing it -with ssh-gate would force ssh-gate above L4 and into git-protocol -land. See `docs/prds/0008-git-gate.md`. - -This module defines the abstract gate (`GitGate`) and its plan -dataclass (`GitGatePlan`). The gateway's start/stop lifecycle is -backend-specific and lives on concrete subclasses (see -`bot_bottle/backend/docker/git_gate.py`).""" - - -from __future__ import annotations - -from abc import ABC -from dataclasses import dataclass -from pathlib import Path - -from .manifest import ManifestBottle - -# Rendering and the deploy-key lifecycle live in sibling modules; the -# names are re-exported here (see __all__) so existing -# `from bot_bottle.git_gate import …` callers are unchanged. -from .gateway.git_gate_render import ( - GIT_GATE_HOSTNAME, - GIT_GATE_TIMEOUT_SECS, - GitGateUpstream, - git_gate_known_hosts_line, - git_gate_render_access_hook, - git_gate_render_entrypoint, - git_gate_render_provision, - git_gate_render_gitconfig, - git_gate_render_hook, - git_gate_upstreams_for_bottle, - _gitconfig_validate_value, -) -from .git_gate_provision import ( - provision_git_gate_dynamic_keys, - revoke_git_gate_provisioned_keys, - _provision_dynamic_key, - _resolve_identity_file, -) - -@dataclass(frozen=True) -class GitGatePlan: - """Output of GitGate.prepare; consumed by .start. - - The script + slug + upstream fields are filled at prepare time - (host-side, side-effect-free on docker). The network fields are - populated by the backend's launch step via `dataclasses.replace` - once those networks exist. Empty defaults are sentinels meaning - "not yet set"; `.start` validates that they are populated. - - `hook_script` is the shared `pre-receive` for push-time gating; - `access_hook_script` is `git daemon`'s `--access-hook` for the - fetch-time upstream refresh.""" - - slug: str - entrypoint_script: Path - hook_script: Path - access_hook_script: Path - upstreams: tuple[GitGateUpstream, ...] - internal_network: str = "" - egress_network: str = "" - - - - -class GitGate(ABC): - """The per-agent git-gate. Encapsulates the host-side prepare - (upstream lift + entrypoint/hook render); the gateway's - start/stop lifecycle is backend-specific and lives on concrete - subclasses.""" - - def prepare(self, bottle: ManifestBottle, slug: str, stage_dir: Path) -> GitGatePlan: - """Compute the upstream table from `bottle.git` and write the - entrypoint, pre-receive hook, and access-hook scripts (mode - 600) under `stage_dir`. Pure host-side, no docker subprocess. - - 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 = git_gate_upstreams_for_bottle(bottle) - entrypoint = stage_dir / "git_gate_entrypoint.sh" - entrypoint.write_text(git_gate_render_entrypoint(upstreams)) - entrypoint.chmod(0o600) - hook = stage_dir / "git_gate_pre_receive.sh" - hook.write_text(git_gate_render_hook()) - hook.chmod(0o600) - access_hook = stage_dir / "git_gate_access_hook.sh" - access_hook.write_text(git_gate_render_access_hook()) - # 0o700 (not 0o600): git daemon execs --access-hook directly, - # not via `sh`, so the script needs the x bit. The gateway copy - # does not necessarily preserve this mode (`docker cp` does, the - # Apple `container cp` does not), so provision_git_gate re-applies - # +x on the gateway side — see backend/docker/gateway_provision.py. - access_hook.chmod(0o700) - upstreams_with_files: list[GitGateUpstream] = [] - for u in upstreams: - known_hosts_file = Path() - if u.known_host_key: - known_hosts_file = stage_dir / f"{u.name}-known_hosts" - known_hosts_file.write_text( - git_gate_known_hosts_line( - u.upstream_host, u.upstream_port, u.known_host_key, - ) - ) - known_hosts_file.chmod(0o600) - upstreams_with_files.append( - GitGateUpstream( - 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, - known_hosts_file=known_hosts_file, - ) - ) - return GitGatePlan( - slug=slug, - entrypoint_script=entrypoint, - hook_script=hook, - access_hook_script=access_hook, - upstreams=tuple(upstreams_with_files), - ) - - -__all__ = [ - "GIT_GATE_HOSTNAME", - "GIT_GATE_TIMEOUT_SECS", - "GitGateUpstream", - "GitGatePlan", - "GitGate", - "git_gate_upstreams_for_bottle", - "git_gate_render_gitconfig", - "git_gate_known_hosts_line", - "git_gate_render_entrypoint", - "git_gate_render_provision", - "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", - "_resolve_identity_file", -] diff --git a/bot_bottle/git_gate/__init__.py b/bot_bottle/git_gate/__init__.py new file mode 100644 index 0000000..c902a1f --- /dev/null +++ b/bot_bottle/git_gate/__init__.py @@ -0,0 +1,98 @@ +"""Per-agent git-gate (PRD 0008). + +The git-gate fronts a bottle's declared git upstreams as a transparent mirror: +a `pre-receive` hook gitleaks-scans pushes and forwards clean refs to the real +upstream with a gate-resident credential; an `--access-hook` refreshes from the +upstream before fetches. The agent never sees the upstream credential. + +Layout: + + * `service` — the `GitGate` host-side service (prepare / provision / revoke / + preflight) the backend drives at launch. + * `plan` — `GitGatePlan`, the launch DTO (in the backend contract). + * `provision`, `host_key` — the deploy-key + host-key host-side helpers the + service delegates to. + +The rendering + the in-gateway hook execution live in +`bot_bottle.gateway.git_gate_render`. The public names are re-exported lazily +via `__getattr__`, so `from bot_bottle.git_gate import …` keeps working and +importing `git_gate.plan` (the contract's dependency) doesn't drag in the +provisioning / forge-API code. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .service import GitGate + from .plan import GitGatePlan + from .provision import ( + provision_git_gate_dynamic_keys, + revoke_git_gate_provisioned_keys, + ) + from ..gateway.git_gate_render import ( + GIT_GATE_HOSTNAME, + GIT_GATE_TIMEOUT_SECS, + GitGateUpstream, + git_gate_known_hosts_line, + git_gate_render_access_hook, + git_gate_render_entrypoint, + git_gate_render_gitconfig, + git_gate_render_hook, + git_gate_render_provision, + git_gate_upstreams_for_bottle, + ) + + +# Public name -> relative module that defines it. Render/hook names come from +# the gateway (where the in-gateway execution lives); the service, plan, and +# provisioning are this package's own submodules. +_LAZY: dict[str, str] = { + "GitGate": ".service", + "GitGatePlan": ".plan", + "provision_git_gate_dynamic_keys": ".provision", + "revoke_git_gate_provisioned_keys": ".provision", + "_provision_dynamic_key": ".provision", + "_resolve_identity_file": ".provision", + "GIT_GATE_HOSTNAME": "..gateway.git_gate_render", + "GIT_GATE_TIMEOUT_SECS": "..gateway.git_gate_render", + "GitGateUpstream": "..gateway.git_gate_render", + "git_gate_upstreams_for_bottle": "..gateway.git_gate_render", + "git_gate_render_gitconfig": "..gateway.git_gate_render", + "git_gate_known_hosts_line": "..gateway.git_gate_render", + "git_gate_render_entrypoint": "..gateway.git_gate_render", + "git_gate_render_provision": "..gateway.git_gate_render", + "git_gate_render_hook": "..gateway.git_gate_render", + "git_gate_render_access_hook": "..gateway.git_gate_render", + "_gitconfig_validate_value": "..gateway.git_gate_render", +} + + +def __getattr__(name: str) -> Any: + src = _LAZY.get(name) + if src is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + from importlib import import_module + + value = getattr(import_module(src, __name__), name) + globals()[name] = value + return value + + +__all__ = [ + "GitGate", + "GitGatePlan", + "GitGateUpstream", + "GIT_GATE_HOSTNAME", + "GIT_GATE_TIMEOUT_SECS", + "git_gate_upstreams_for_bottle", + "git_gate_render_gitconfig", + "git_gate_known_hosts_line", + "git_gate_render_entrypoint", + "git_gate_render_provision", + "git_gate_render_hook", + "git_gate_render_access_hook", + "provision_git_gate_dynamic_keys", + "revoke_git_gate_provisioned_keys", +] diff --git a/bot_bottle/git_gate_host_key.py b/bot_bottle/git_gate/host_key.py similarity index 98% rename from bot_bottle/git_gate_host_key.py rename to bot_bottle/git_gate/host_key.py index 2210e8e..5c2ba78 100644 --- a/bot_bottle/git_gate_host_key.py +++ b/bot_bottle/git_gate/host_key.py @@ -16,9 +16,9 @@ import sys from pathlib import Path from typing import cast -from .log import die, info -from .manifest import Manifest -from .yaml_subset import YamlSubsetError, parse_frontmatter, serialize_yaml_subset +from ..log import die, info +from ..manifest import Manifest +from ..yaml_subset import YamlSubsetError, parse_frontmatter, serialize_yaml_subset # Preferred key types, most secure first. diff --git a/bot_bottle/git_gate/plan.py b/bot_bottle/git_gate/plan.py new file mode 100644 index 0000000..c5fe3bb --- /dev/null +++ b/bot_bottle/git_gate/plan.py @@ -0,0 +1,36 @@ +"""`GitGatePlan` — the git-gate launch plan (DTO). + +The output of `GitGate.prepare`, consumed by the backend launch step and the +`BottlePlan` contract. A pure value type (its `upstreams` element type +`GitGateUpstream` is the neutral render dataclass). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from ..gateway.git_gate_render import GitGateUpstream + + +@dataclass(frozen=True) +class GitGatePlan: + """Output of GitGate.prepare; consumed by .start. + + The script + slug + upstream fields are filled at prepare time (host-side, + side-effect-free on docker). The network fields are populated by the + backend's launch step via `dataclasses.replace` once those networks exist. + Empty defaults are sentinels meaning "not yet set"; `.start` validates that + they are populated. + + `hook_script` is the shared `pre-receive` for push-time gating; + `access_hook_script` is `git daemon`'s `--access-hook` for the fetch-time + upstream refresh.""" + + slug: str + entrypoint_script: Path + hook_script: Path + access_hook_script: Path + upstreams: tuple[GitGateUpstream, ...] + internal_network: str = "" + egress_network: str = "" diff --git a/bot_bottle/git_gate_provision.py b/bot_bottle/git_gate/provision.py similarity index 93% rename from bot_bottle/git_gate_provision.py rename to bot_bottle/git_gate/provision.py index ac9eebd..007abd3 100644 --- a/bot_bottle/git_gate_provision.py +++ b/bot_bottle/git_gate/provision.py @@ -13,14 +13,14 @@ 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 +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 .git_gate import GitGatePlan + from .plan import GitGatePlan def _provision_dynamic_key( entry: ManifestGitEntry, @@ -32,7 +32,7 @@ def _provision_dynamic_key( 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 + from ..deploy_key_provisioner import get_provisioner pk = entry.Key token = os.environ.get(pk.forge_token_env) if token is None: @@ -70,7 +70,7 @@ def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) -> 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 + from ..deploy_key_provisioner import get_provisioner for entry in bottle.git: if entry.Key.provider != "gitea": continue diff --git a/bot_bottle/git_gate/service.py b/bot_bottle/git_gate/service.py new file mode 100644 index 0000000..edd592b --- /dev/null +++ b/bot_bottle/git_gate/service.py @@ -0,0 +1,114 @@ +"""The `GitGate` host-side service (PRD 0008). + +The git-gate fronts a bottle's declared git upstreams as a transparent mirror: +a `pre-receive` hook gitleaks-scans pushes and forwards clean refs to the real +upstream with a gate-resident credential; an `--access-hook` refreshes from the +upstream before fetches. The agent never sees the upstream credential. + +`GitGate` is the host-side service the backend drives at launch: build the plan +(`prepare`), provision / revoke the per-upstream deploy keys, and preflight the +upstream host keys. The rendering it emits + the in-gateway hook execution live +in `bot_bottle.gateway.git_gate_render`. +""" + +from __future__ import annotations + +from pathlib import Path + +from ..manifest import Manifest, ManifestBottle +from ..gateway.git_gate_render import ( + GitGateUpstream, + git_gate_known_hosts_line, + git_gate_render_access_hook, + git_gate_render_entrypoint, + git_gate_render_hook, + git_gate_upstreams_for_bottle, +) +from .plan import GitGatePlan +from . import provision as _provision +from . import host_key as _host_key + + +class GitGate: + """The per-agent git-gate host-side service. Stateless — the backend's + launch step drives `prepare` → `provision_dynamic_keys` and, at teardown, + `revoke_provisioned_keys`; `preflight_host_keys` gates a launch on the + upstream host keys being known.""" + + def prepare(self, bottle: ManifestBottle, slug: str, stage_dir: Path) -> GitGatePlan: + """Compute the upstream table from `bottle.git` and write the + entrypoint, pre-receive hook, and access-hook scripts (mode 600) under + `stage_dir`. Pure host-side, no docker subprocess. + + 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 = git_gate_upstreams_for_bottle(bottle) + entrypoint = stage_dir / "git_gate_entrypoint.sh" + entrypoint.write_text(git_gate_render_entrypoint(upstreams)) + entrypoint.chmod(0o600) + hook = stage_dir / "git_gate_pre_receive.sh" + hook.write_text(git_gate_render_hook()) + hook.chmod(0o600) + access_hook = stage_dir / "git_gate_access_hook.sh" + access_hook.write_text(git_gate_render_access_hook()) + # 0o700 (not 0o600): git daemon execs --access-hook directly, not via + # `sh`, so the script needs the x bit. The gateway copy does not + # necessarily preserve this mode (`docker cp` does, the Apple `container + # cp` does not), so provisioning re-applies +x on the gateway side — see + # backend/docker/gateway_provision.py. + access_hook.chmod(0o700) + upstreams_with_files: list[GitGateUpstream] = [] + for u in upstreams: + known_hosts_file = Path() + if u.known_host_key: + known_hosts_file = stage_dir / f"{u.name}-known_hosts" + known_hosts_file.write_text( + git_gate_known_hosts_line( + u.upstream_host, u.upstream_port, u.known_host_key, + ) + ) + known_hosts_file.chmod(0o600) + upstreams_with_files.append( + GitGateUpstream( + 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, + known_hosts_file=known_hosts_file, + ) + ) + return GitGatePlan( + slug=slug, + entrypoint_script=entrypoint, + hook_script=hook, + access_hook_script=access_hook, + upstreams=tuple(upstreams_with_files), + ) + + def provision_dynamic_keys( + self, bottle: ManifestBottle, plan: GitGatePlan, stage_dir: Path, + ) -> GitGatePlan: + """Mint the `gitea` upstreams' ephemeral deploy keys via the forge API + and return the plan with their identity files filled in.""" + return _provision.provision_git_gate_dynamic_keys(bottle, plan, stage_dir) + + def revoke_provisioned_keys(self, bottle: ManifestBottle, stage_dir: Path) -> None: + """Revoke every deploy key provisioned for `bottle` (teardown).""" + _provision.revoke_git_gate_provisioned_keys(bottle, stage_dir) + + def preflight_host_keys( + self, manifest: Manifest, *, headless: bool, home_md: Path | None, + ) -> Manifest: + """Ensure every git-gate upstream has a known host key, populating + missing ones (or erroring in headless mode). Returns the manifest, + possibly updated with fetched keys.""" + return _host_key.preflight_host_keys( + manifest, headless=headless, home_md=home_md, + ) diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index 4befb5d..bf7a891 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -385,13 +385,13 @@ class TestDynamicKeyProvisioning(unittest.TestCase): def test_resolve_identity_file_gitea_provisions_key(self): entry = self._gitea_manifest().bottles["dev"].git[0] - with patch("bot_bottle.git_gate_provision._provision_dynamic_key", return_value="/tmp/provisioned-key") as mock_provision: + with patch("bot_bottle.git_gate.provision._provision_dynamic_key", return_value="/tmp/provisioned-key") as mock_provision: 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: + 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() @@ -402,7 +402,7 @@ class TestDynamicKeyProvisioning(unittest.TestCase): plan = _StubGate().prepare(bottle, "demo", self.stage) with patch( - "bot_bottle.git_gate_provision._provision_dynamic_key", + "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) diff --git a/tests/unit/test_git_gate_host_key.py b/tests/unit/test_git_gate_host_key.py index 3835d14..4a6541c 100644 --- a/tests/unit/test_git_gate_host_key.py +++ b/tests/unit/test_git_gate_host_key.py @@ -7,7 +7,7 @@ import unittest from pathlib import Path from unittest.mock import MagicMock, patch -from bot_bottle.git_gate_host_key import ( +from bot_bottle.git_gate.host_key import ( add_host_key_to_frontmatter, find_and_update_bottle_file, find_repo_bottle_file, @@ -358,7 +358,7 @@ git-gate: with tempfile.TemporaryDirectory() as d: path = self._write_bottle(d, "dev", content) with patch( - "bot_bottle.git_gate_host_key.find_repo_bottle_file", + "bot_bottle.git_gate.host_key.find_repo_bottle_file", return_value=path, ), patch.object(Path, "read_text", side_effect=OSError("Permission denied")): result = find_and_update_bottle_file(Path(d), "myrepo", "ssh-ed25519 AAAA") @@ -382,7 +382,7 @@ git-gate: with tempfile.TemporaryDirectory() as d: path = self._write_bottle(d, "dev", content) with patch( - "bot_bottle.git_gate_host_key.add_host_key_to_frontmatter", + "bot_bottle.git_gate.host_key.add_host_key_to_frontmatter", return_value=content, # no change ): result = find_and_update_bottle_file(Path(d), "myrepo", "ssh-ed25519 AAAA") @@ -467,10 +467,10 @@ git-gate: mock_prompt = MagicMock(side_effect=["y", "n"]) # confirm key, skip save with patch( - "bot_bottle.git_gate_host_key.fetch_host_key", + "bot_bottle.git_gate.host_key.fetch_host_key", return_value="ssh-ed25519 FETCHED", ), patch( - "bot_bottle.git_gate_host_key.prompt_tty", mock_prompt, + "bot_bottle.git_gate.host_key.prompt_tty", mock_prompt, ), patch("sys.stderr"): preflight_host_keys(manifest, headless=False, home_md=Path(home)) @@ -520,10 +520,10 @@ class TestPreflightHostKeys(unittest.TestCase): manifest = _manifest_with_git() with patch( - "bot_bottle.git_gate_host_key.fetch_host_key", + "bot_bottle.git_gate.host_key.fetch_host_key", return_value="ssh-ed25519 FETCHED", ), patch( - "bot_bottle.git_gate_host_key.prompt_tty", + "bot_bottle.git_gate.host_key.prompt_tty", side_effect=["y", "n"], # confirm key, don't persist ), patch("sys.stderr"): result = preflight_host_keys(manifest, headless=False, home_md=None) @@ -534,10 +534,10 @@ class TestPreflightHostKeys(unittest.TestCase): manifest = _manifest_with_git() with patch( - "bot_bottle.git_gate_host_key.fetch_host_key", + "bot_bottle.git_gate.host_key.fetch_host_key", return_value="ssh-ed25519 FETCHED", ), patch( - "bot_bottle.git_gate_host_key.prompt_tty", + "bot_bottle.git_gate.host_key.prompt_tty", return_value="n", ), patch("sys.stderr"), self.assertRaises(SystemExit): preflight_host_keys(manifest, headless=False, home_md=None) @@ -557,10 +557,10 @@ class TestPreflightHostKeys(unittest.TestCase): ) with patch( - "bot_bottle.git_gate_host_key.fetch_host_key", + "bot_bottle.git_gate.host_key.fetch_host_key", return_value="ssh-ed25519 FETCHED", ), patch( - "bot_bottle.git_gate_host_key.prompt_tty", + "bot_bottle.git_gate.host_key.prompt_tty", side_effect=["y", "y"], # confirm key, yes persist ), patch("sys.stderr"): result = preflight_host_keys( @@ -586,10 +586,10 @@ class TestPreflightHostKeys(unittest.TestCase): ) with patch( - "bot_bottle.git_gate_host_key.fetch_host_key", + "bot_bottle.git_gate.host_key.fetch_host_key", return_value="ssh-ed25519 FETCHED", ), patch( - "bot_bottle.git_gate_host_key.prompt_tty", + "bot_bottle.git_gate.host_key.prompt_tty", side_effect=["y", "n"], # confirm key, no persist ), patch("sys.stderr"): result = preflight_host_keys( @@ -604,7 +604,7 @@ class TestPreflightHostKeys(unittest.TestCase): manifest = _manifest_with_git() with patch( - "bot_bottle.git_gate_host_key.fetch_host_key", + "bot_bottle.git_gate.host_key.fetch_host_key", side_effect=RuntimeError("connection refused"), ), patch("sys.stderr"), self.assertRaises(SystemExit): preflight_host_keys(manifest, headless=False, home_md=None) @@ -620,10 +620,10 @@ class TestPreflightHostKeys(unittest.TestCase): import io buf = io.StringIO() with patch( - "bot_bottle.git_gate_host_key.fetch_host_key", + "bot_bottle.git_gate.host_key.fetch_host_key", return_value="ssh-ed25519 FETCHED", ), patch( - "bot_bottle.git_gate_host_key.prompt_tty", + "bot_bottle.git_gate.host_key.prompt_tty", side_effect=["y"], # confirm key only; no save prompt when no file found ), patch("sys.stderr", buf): result = preflight_host_keys( @@ -651,13 +651,13 @@ class TestPreflightHostKeys(unittest.TestCase): import io buf = io.StringIO() with patch( - "bot_bottle.git_gate_host_key.fetch_host_key", + "bot_bottle.git_gate.host_key.fetch_host_key", return_value="ssh-ed25519 FETCHED", ), patch( - "bot_bottle.git_gate_host_key.prompt_tty", + "bot_bottle.git_gate.host_key.prompt_tty", side_effect=["y", "y"], # confirm key, yes persist ), patch( - "bot_bottle.git_gate_host_key.find_and_update_bottle_file", + "bot_bottle.git_gate.host_key.find_and_update_bottle_file", return_value=False, ), patch("sys.stderr", buf): result = preflight_host_keys(