450037b7e9
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>
115 lines
5.0 KiB
Python
115 lines
5.0 KiB
Python
"""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,
|
|
)
|