refactor(git-gate): make GitGate a service class in a git_gate package
test / integration-docker (pull_request) Successful in 15s
tracker-policy-pr / check-pr (pull_request) Successful in 10s
test / unit (pull_request) Successful in 47s
lint / lint (push) Failing after 58s
test / integration-firecracker (pull_request) Successful in 3m17s
test / coverage (pull_request) Successful in 20s
test / publish-infra (pull_request) Has been skipped

Turn the git_gate module into a package with GitGate as a concrete service class
(dropping the ABC), mirroring the Supervisor shape. The host-side git-gate
operations are now methods the backend drives:

  git_gate/
    __init__.py  — thin __getattr__ facade (keeps `from bot_bottle.git_gate
                   import …` working; render/hook names lazily forwarded to
                   gateway.git_gate_render)
    plan.py      — GitGatePlan (the launch DTO the backend contract references)
    service.py   — GitGate: prepare / provision_dynamic_keys /
                   revoke_provisioned_keys / preflight_host_keys
    provision.py — deploy-key lifecycle (was git_gate_provision.py)
    host_key.py  — host-key preflight (was git_gate_host_key.py)

The backend launch + base.py preflight now call GitGate() methods instead of the
free functions. The runtime rendering / in-gateway hook execution stay in
gateway/git_gate_render.py (data plane) — only the host-side service moved.

Because the facade forwards names lazily, the ~25 `from bot_bottle.git_gate
import GitGatePlan` call-sites are unchanged, and importing git_gate.plan (the
contract's dependency) no longer drags in the provisioning / forge-API code.

Full unit suite green (2243).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 16:27:56 -04:00
parent 14c28946a7
commit 450037b7e9
13 changed files with 295 additions and 225 deletions
+2 -2
View File
@@ -309,8 +309,8 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
self._preflight() self._preflight()
from ..git_gate_host_key import preflight_host_keys from ..git_gate import GitGate
manifest = preflight_host_keys( manifest = GitGate().preflight_host_keys(
manifest, manifest,
headless=spec.headless, headless=spec.headless,
home_md=spec.manifest.home_md, home_md=spec.manifest.home_md,
+3 -6
View File
@@ -38,10 +38,7 @@ from typing import Callable, Generator
from ...agent_provider import runtime_for from ...agent_provider import runtime_for
from ...egress import egress_resolve_token_values from ...egress import egress_resolve_token_values
from ...git_gate import ( from ...git_gate import GitGate
provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys,
)
from ...image_cache import check_stale from ...image_cache import check_stale
from ...log import die, info, warn from ...log import die, info, warn
from .. import BottleImages from .. import BottleImages
@@ -126,7 +123,7 @@ def launch(
f"teardown failed for container {plan.container_name}" f"teardown failed for container {plan.container_name}"
f" (compose-down): {exc!r}" f" (compose-down): {exc!r}"
) )
revoke_git_gate_provisioned_keys( GitGate().revoke_provisioned_keys(
_bottle_for_revoke, _git_gate_dir_for_revoke _bottle_for_revoke, _git_gate_dir_for_revoke
) )
@@ -135,7 +132,7 @@ def launch(
# provisioning the bottle's repos into the shared gateway. # provisioning the bottle's repos into the shared gateway.
git_gate_plan = plan.git_gate_plan git_gate_plan = plan.git_gate_plan
if git_gate_plan.upstreams: 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), plan.manifest.bottle, git_gate_plan, git_gate_state_dir(plan.slug),
) )
+3 -6
View File
@@ -42,10 +42,7 @@ from ...egress import (
egress_agent_env_entries, egress_agent_env_entries,
egress_resolve_token_values, egress_resolve_token_values,
) )
from ...git_gate import ( from ...git_gate import GitGate
provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys,
)
from ...image_cache import check_stale_path from ...image_cache import check_stale_path
from ...log import die, info, warn from ...log import die, info, warn
from ...supervisor.types import SUPERVISE_PORT from ...supervisor.types import SUPERVISE_PORT
@@ -86,7 +83,7 @@ def launch(
except BaseException as exc: # noqa: W0718 - teardown must continue except BaseException as exc: # noqa: W0718 - teardown must continue
teardown_exc = exc teardown_exc = exc
warn(f"firecracker teardown failed: {exc!r}") 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: if teardown_exc is not None:
raise teardown_exc raise teardown_exc
@@ -97,7 +94,7 @@ def launch(
# Step 2: mint the git-gate dynamic (gitea) deploy keys, if any. # Step 2: mint the git-gate dynamic (gitea) deploy keys, if any.
git_gate_plan = plan.git_gate_plan git_gate_plan = plan.git_gate_plan
if git_gate_plan.upstreams: 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), plan.manifest.bottle, git_gate_plan, git_gate_state_dir(plan.slug),
) )
+3 -6
View File
@@ -48,10 +48,7 @@ from ...egress import (
egress_agent_env_entries, egress_agent_env_entries,
egress_resolve_token_values, egress_resolve_token_values,
) )
from ...git_gate import ( from ...git_gate import GitGate
provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys,
)
from ...gateway.git_http_backend import DEFAULT_PORT as _GIT_HTTP_PORT from ...gateway.git_http_backend import DEFAULT_PORT as _GIT_HTTP_PORT
from ...image_cache import check_stale from ...image_cache import check_stale
from ...log import die, info, warn from ...log import die, info, warn
@@ -151,7 +148,7 @@ def launch(
except BaseException as exc: # noqa: W0718 - teardown must continue except BaseException as exc: # noqa: W0718 - teardown must continue
teardown_exc = exc teardown_exc = exc
warn(f"macos-container teardown failed: {exc!r}") 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: if teardown_exc is not None:
raise teardown_exc raise teardown_exc
@@ -282,7 +279,7 @@ def _provision_git_gate_keys(
) -> MacosContainerBottlePlan: ) -> MacosContainerBottlePlan:
if not plan.git_gate_plan.upstreams: if not plan.git_gate_plan.upstreams:
return plan return plan
git_gate_plan = provision_git_gate_dynamic_keys( git_gate_plan = GitGate().provision_dynamic_keys(
plan.manifest.bottle, plan.manifest.bottle,
plan.git_gate_plan, plan.git_gate_plan,
git_gate_state_dir(plan.slug), git_gate_state_dir(plan.slug),
+3 -3
View File
@@ -3,9 +3,9 @@
Builds the agent's `.gitconfig` insteadOf rewrites, the known_hosts Builds the agent's `.gitconfig` insteadOf rewrites, the known_hosts
line, and the entrypoint / pre-receive / access-hook scripts the gateway line, and the entrypoint / pre-receive / access-hook scripts the gateway
runs. No docker or forge calls — exposed for tests and reuse across runs. No docker or forge calls — exposed for tests and reuse across
backends. Split out of `git_gate.py` so the control surface (`GitGate`) backends. The host-side service (`git_gate.GitGate`) and the deploy-key
and the deploy-key lifecycle (`git_gate_provision`) each read on their lifecycle (`git_gate.provision`) each read on their own; `git_gate`
own; `git_gate` re-exports these names for API stability.""" re-exports these names for API stability."""
from __future__ import annotations from __future__ import annotations
-169
View File
@@ -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://<gate>/<name>.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",
]
+98
View File
@@ -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",
]
@@ -16,9 +16,9 @@ import sys
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
from .log import die, info from ..log import die, info
from .manifest import Manifest from ..manifest import Manifest
from .yaml_subset import YamlSubsetError, parse_frontmatter, serialize_yaml_subset from ..yaml_subset import YamlSubsetError, parse_frontmatter, serialize_yaml_subset
# Preferred key types, most secure first. # Preferred key types, most secure first.
+36
View File
@@ -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 = ""
@@ -13,14 +13,14 @@ import dataclasses
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .bottle_state import globalize_slug from ..bottle_state import globalize_slug
from .errors import MissingEnvVarError from ..errors import MissingEnvVarError
from .log import info from ..log import info
from .manifest import ManifestBottle, ManifestGitEntry from ..manifest import ManifestBottle, ManifestGitEntry
from .gateway.git_gate_render import GitGateUpstream from ..gateway.git_gate_render import GitGateUpstream
if TYPE_CHECKING: if TYPE_CHECKING:
from .git_gate import GitGatePlan from .plan import GitGatePlan
def _provision_dynamic_key( def _provision_dynamic_key(
entry: ManifestGitEntry, entry: ManifestGitEntry,
@@ -32,7 +32,7 @@ def _provision_dynamic_key(
Returns the host-side path to the private key file so the caller Returns the host-side path to the private key file so the caller
can inject it into the GitGateUpstream as `identity_file`.""" 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 pk = entry.Key
token = os.environ.get(pk.forge_token_env) token = os.environ.get(pk.forge_token_env)
if token is None: 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 Called at teardown after containers stop. Raises if any revocation
fails a stranded key is a security concern that the operator must fails a stranded key is a security concern that the operator must
address manually.""" address manually."""
from .deploy_key_provisioner import get_provisioner from ..deploy_key_provisioner import get_provisioner
for entry in bottle.git: for entry in bottle.git:
if entry.Key.provider != "gitea": if entry.Key.provider != "gitea":
continue continue
+114
View File
@@ -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,
)
+3 -3
View File
@@ -385,13 +385,13 @@ class TestDynamicKeyProvisioning(unittest.TestCase):
def test_resolve_identity_file_gitea_provisions_key(self): def test_resolve_identity_file_gitea_provisions_key(self):
entry = self._gitea_manifest().bottles["dev"].git[0] 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)) self.assertEqual("/tmp/provisioned-key", _resolve_identity_file(entry, "demo", self.stage))
mock_provision.assert_called_once() mock_provision.assert_called_once()
def test_prepare_defers_gitea_key_provisioning(self): def test_prepare_defers_gitea_key_provisioning(self):
bottle = self._gitea_manifest().bottles["dev"] 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) plan = _StubGate().prepare(bottle, "demo", self.stage)
mock_provision.assert_not_called() mock_provision.assert_not_called()
@@ -402,7 +402,7 @@ class TestDynamicKeyProvisioning(unittest.TestCase):
plan = _StubGate().prepare(bottle, "demo", self.stage) plan = _StubGate().prepare(bottle, "demo", self.stage)
with patch( with patch(
"bot_bottle.git_gate_provision._provision_dynamic_key", "bot_bottle.git_gate.provision._provision_dynamic_key",
return_value="/tmp/provisioned-key", return_value="/tmp/provisioned-key",
) as mock_provision: ) as mock_provision:
updated = provision_git_gate_dynamic_keys(bottle, plan, self.stage) updated = provision_git_gate_dynamic_keys(bottle, plan, self.stage)
+19 -19
View File
@@ -7,7 +7,7 @@ import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch 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, add_host_key_to_frontmatter,
find_and_update_bottle_file, find_and_update_bottle_file,
find_repo_bottle_file, find_repo_bottle_file,
@@ -358,7 +358,7 @@ git-gate:
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
path = self._write_bottle(d, "dev", content) path = self._write_bottle(d, "dev", content)
with patch( 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, return_value=path,
), patch.object(Path, "read_text", side_effect=OSError("Permission denied")): ), patch.object(Path, "read_text", side_effect=OSError("Permission denied")):
result = find_and_update_bottle_file(Path(d), "myrepo", "ssh-ed25519 AAAA") result = find_and_update_bottle_file(Path(d), "myrepo", "ssh-ed25519 AAAA")
@@ -382,7 +382,7 @@ git-gate:
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
path = self._write_bottle(d, "dev", content) path = self._write_bottle(d, "dev", content)
with patch( 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 return_value=content, # no change
): ):
result = find_and_update_bottle_file(Path(d), "myrepo", "ssh-ed25519 AAAA") 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 mock_prompt = MagicMock(side_effect=["y", "n"]) # confirm key, skip save
with patch( 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", return_value="ssh-ed25519 FETCHED",
), patch( ), patch(
"bot_bottle.git_gate_host_key.prompt_tty", mock_prompt, "bot_bottle.git_gate.host_key.prompt_tty", mock_prompt,
), patch("sys.stderr"): ), patch("sys.stderr"):
preflight_host_keys(manifest, headless=False, home_md=Path(home)) preflight_host_keys(manifest, headless=False, home_md=Path(home))
@@ -520,10 +520,10 @@ class TestPreflightHostKeys(unittest.TestCase):
manifest = _manifest_with_git() manifest = _manifest_with_git()
with patch( 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", return_value="ssh-ed25519 FETCHED",
), patch( ), 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 side_effect=["y", "n"], # confirm key, don't persist
), patch("sys.stderr"): ), patch("sys.stderr"):
result = preflight_host_keys(manifest, headless=False, home_md=None) result = preflight_host_keys(manifest, headless=False, home_md=None)
@@ -534,10 +534,10 @@ class TestPreflightHostKeys(unittest.TestCase):
manifest = _manifest_with_git() manifest = _manifest_with_git()
with patch( 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", return_value="ssh-ed25519 FETCHED",
), patch( ), patch(
"bot_bottle.git_gate_host_key.prompt_tty", "bot_bottle.git_gate.host_key.prompt_tty",
return_value="n", return_value="n",
), patch("sys.stderr"), self.assertRaises(SystemExit): ), patch("sys.stderr"), self.assertRaises(SystemExit):
preflight_host_keys(manifest, headless=False, home_md=None) preflight_host_keys(manifest, headless=False, home_md=None)
@@ -557,10 +557,10 @@ class TestPreflightHostKeys(unittest.TestCase):
) )
with patch( 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", return_value="ssh-ed25519 FETCHED",
), patch( ), 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 side_effect=["y", "y"], # confirm key, yes persist
), patch("sys.stderr"): ), patch("sys.stderr"):
result = preflight_host_keys( result = preflight_host_keys(
@@ -586,10 +586,10 @@ class TestPreflightHostKeys(unittest.TestCase):
) )
with patch( 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", return_value="ssh-ed25519 FETCHED",
), patch( ), 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 side_effect=["y", "n"], # confirm key, no persist
), patch("sys.stderr"): ), patch("sys.stderr"):
result = preflight_host_keys( result = preflight_host_keys(
@@ -604,7 +604,7 @@ class TestPreflightHostKeys(unittest.TestCase):
manifest = _manifest_with_git() manifest = _manifest_with_git()
with patch( 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"), side_effect=RuntimeError("connection refused"),
), patch("sys.stderr"), self.assertRaises(SystemExit): ), patch("sys.stderr"), self.assertRaises(SystemExit):
preflight_host_keys(manifest, headless=False, home_md=None) preflight_host_keys(manifest, headless=False, home_md=None)
@@ -620,10 +620,10 @@ class TestPreflightHostKeys(unittest.TestCase):
import io import io
buf = io.StringIO() buf = io.StringIO()
with patch( 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", return_value="ssh-ed25519 FETCHED",
), patch( ), 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 side_effect=["y"], # confirm key only; no save prompt when no file found
), patch("sys.stderr", buf): ), patch("sys.stderr", buf):
result = preflight_host_keys( result = preflight_host_keys(
@@ -651,13 +651,13 @@ class TestPreflightHostKeys(unittest.TestCase):
import io import io
buf = io.StringIO() buf = io.StringIO()
with patch( 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", return_value="ssh-ed25519 FETCHED",
), patch( ), 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 side_effect=["y", "y"], # confirm key, yes persist
), patch( ), 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, return_value=False,
), patch("sys.stderr", buf): ), patch("sys.stderr", buf):
result = preflight_host_keys( result = preflight_host_keys(