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
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:
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Preflight host-key population for git-gate upstreams (issue #333).
|
||||
|
||||
When a git-gate repo entry lacks a `host_key`, this module either:
|
||||
- headless: dies with a clear config error.
|
||||
- interactive: fetches the key via ssh-keyscan, prompts the operator to
|
||||
confirm, and optionally persists it to the bottle config file on disk.
|
||||
|
||||
Public entry point: `preflight_host_keys(manifest, headless=..., home_md=...)`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import subprocess
|
||||
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
|
||||
|
||||
|
||||
# Preferred key types, most secure first.
|
||||
_KEY_TYPE_PREFERENCE = (
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ssh-rsa",
|
||||
)
|
||||
|
||||
|
||||
def fetch_host_key(host: str, port: str) -> str:
|
||||
"""Return an SSH public key for `host`:`port` via ssh-keyscan.
|
||||
|
||||
Returns the key in `<type> <base64-data>` format (the host prefix is
|
||||
stripped so the result can be stored in `host_key` and later formatted
|
||||
into a known_hosts line by `git_gate_known_hosts_line`).
|
||||
|
||||
Prefers ed25519 > ecdsa > rsa; falls back to the first key type
|
||||
returned if none of the preferred types are present.
|
||||
|
||||
Raises `RuntimeError` on subprocess failure, timeout, or no result.
|
||||
Uses only the Python stdlib (subprocess)."""
|
||||
args = ["ssh-keyscan"]
|
||||
if port and port != "22":
|
||||
args += ["-p", port]
|
||||
args.append(host)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
args, capture_output=True, text=True, timeout=15, check=False,
|
||||
)
|
||||
except OSError as e:
|
||||
raise RuntimeError(
|
||||
f"ssh-keyscan: could not launch for {host}:{port}: {e}"
|
||||
) from e
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise RuntimeError(f"ssh-keyscan timed out for {host}:{port}") from e
|
||||
|
||||
# known_hosts format: "[host]:port type data" or "host type data"
|
||||
# Strip the host/port prefix; collect "type -> type data" by type.
|
||||
found: dict[str, str] = {}
|
||||
for line in result.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split(None, 2)
|
||||
if len(parts) == 3 and parts[1] not in found:
|
||||
found[parts[1]] = f"{parts[1]} {parts[2]}"
|
||||
|
||||
for preferred in _KEY_TYPE_PREFERENCE:
|
||||
if preferred in found:
|
||||
return found[preferred]
|
||||
if found:
|
||||
return next(iter(found.values()))
|
||||
|
||||
raise RuntimeError(
|
||||
f"ssh-keyscan returned no host key for {host}:{port}."
|
||||
+ (f" stderr: {result.stderr.strip()!r}" if result.stderr.strip() else "")
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frontmatter editing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def add_host_key_to_frontmatter(file_text: str, repo_name: str, host_key: str) -> str:
|
||||
"""Return an updated copy of `file_text` with `host_key` set on the
|
||||
named repo entry in the YAML frontmatter.
|
||||
|
||||
Parses the frontmatter into a dict, sets the key, and re-serializes.
|
||||
Returns the original text unchanged when: the file has no frontmatter,
|
||||
the git-gate.repos.<repo_name> entry is absent or already has a
|
||||
host_key, or the frontmatter cannot be parsed."""
|
||||
try:
|
||||
fm, body = parse_frontmatter(file_text)
|
||||
except YamlSubsetError:
|
||||
return file_text
|
||||
|
||||
git_gate = fm.get("git-gate")
|
||||
if not isinstance(git_gate, dict):
|
||||
return file_text
|
||||
repos = git_gate.get("repos")
|
||||
if not isinstance(repos, dict):
|
||||
return file_text
|
||||
repo = repos.get(repo_name)
|
||||
if not isinstance(repo, dict):
|
||||
return file_text
|
||||
if repo.get("host_key"):
|
||||
return file_text
|
||||
|
||||
cast(dict[str, object], repo)["host_key"] = host_key
|
||||
return f"---\n{serialize_yaml_subset(fm)}---\n{body}"
|
||||
|
||||
|
||||
def find_repo_bottle_file(bottles_dir: Path, repo_name: str) -> Path | None:
|
||||
"""Return the first `bottles_dir/*.md` that declares `repo_name` without
|
||||
a `host_key`, without modifying anything. Returns None if not found."""
|
||||
if not bottles_dir.is_dir():
|
||||
return None
|
||||
for path in sorted(bottles_dir.glob("*.md")):
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
fm, _ = parse_frontmatter(text)
|
||||
except (OSError, UnicodeDecodeError, YamlSubsetError):
|
||||
continue
|
||||
git_gate = fm.get("git-gate")
|
||||
if not isinstance(git_gate, dict):
|
||||
continue
|
||||
repos = git_gate.get("repos")
|
||||
if not isinstance(repos, dict):
|
||||
continue
|
||||
repo = repos.get(repo_name)
|
||||
if not isinstance(repo, dict) or repo.get("host_key"):
|
||||
continue
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def find_and_update_bottle_file(
|
||||
bottles_dir: Path, repo_name: str, host_key: str,
|
||||
) -> bool:
|
||||
"""Write `host_key` into the bottle file returned by `find_repo_bottle_file`.
|
||||
|
||||
Returns True on success, False when no suitable file is found or the
|
||||
write fails."""
|
||||
path = find_repo_bottle_file(bottles_dir, repo_name)
|
||||
if path is None:
|
||||
return False
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return False
|
||||
updated = add_host_key_to_frontmatter(text, repo_name, host_key)
|
||||
if updated == text:
|
||||
return False
|
||||
path.write_text(updated, encoding="utf-8")
|
||||
info(f"wrote host_key for {repo_name!r} to {path}")
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive prompt helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def prompt_tty(message: str) -> str:
|
||||
"""Write `message` to stderr and read a line from /dev/tty (or stdin)."""
|
||||
sys.stderr.write(message)
|
||||
sys.stderr.flush()
|
||||
try:
|
||||
with open("/dev/tty", "r", encoding="utf-8") as tty:
|
||||
return tty.readline().rstrip("\n")
|
||||
except OSError:
|
||||
return sys.stdin.readline().rstrip("\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def preflight_host_keys(
|
||||
manifest: Manifest,
|
||||
*,
|
||||
headless: bool,
|
||||
home_md: Path | None,
|
||||
) -> Manifest:
|
||||
"""Ensure every git-gate repo entry has a `host_key` configured.
|
||||
|
||||
For entries whose `KnownHostKey` is empty:
|
||||
- headless: calls `die()` with a clear message naming the repos.
|
||||
- interactive: fetches the key via ssh-keyscan, shows it to the
|
||||
operator, and requests confirmation. If accepted, optionally
|
||||
persists it to the bottle config file on disk; the key is always
|
||||
applied in memory for this launch regardless of the persistence
|
||||
choice. Aborted confirmation calls `die()`.
|
||||
|
||||
Returns a (possibly updated) Manifest. If all entries already have
|
||||
host keys the original manifest is returned unchanged."""
|
||||
bottle = manifest.bottle
|
||||
missing = [e for e in bottle.git if not e.KnownHostKey]
|
||||
if not missing:
|
||||
return manifest
|
||||
|
||||
if headless:
|
||||
names = ", ".join(repr(e.Name) for e in missing)
|
||||
die(
|
||||
f"git-gate: no host_key configured for repo(s) {names}. "
|
||||
f"Add host_key to each bottle git-gate.repos entry, or run "
|
||||
f"interactively once to have it fetched and saved automatically."
|
||||
)
|
||||
|
||||
bottles_dir = (home_md / "bottles") if home_md is not None else None
|
||||
updated_entries = list(bottle.git)
|
||||
|
||||
for entry in missing:
|
||||
host = entry.UpstreamHost
|
||||
port = entry.UpstreamPort
|
||||
label = f"git-gate.repos[{entry.Name!r}]"
|
||||
|
||||
info(f"{label}: no host_key configured; fetching from {host}:{port}")
|
||||
try:
|
||||
key = fetch_host_key(host, port)
|
||||
except RuntimeError as e:
|
||||
die(f"git-gate: {label}: {e}")
|
||||
|
||||
sys.stderr.write(f"\ngit-gate: host key for {label}:\n {key}\n\n")
|
||||
confirm = prompt_tty("Is this host key correct? [y/N] ")
|
||||
if confirm.strip().lower() not in ("y", "yes"):
|
||||
die(f"git-gate: {label}: host key not confirmed; aborting launch")
|
||||
|
||||
if bottles_dir is not None:
|
||||
target_file = find_repo_bottle_file(bottles_dir, entry.Name)
|
||||
if target_file is not None:
|
||||
save = prompt_tty(
|
||||
f"Save host_key for {entry.Name!r} to {target_file}? [y/N] "
|
||||
)
|
||||
if save.strip().lower() in ("y", "yes"):
|
||||
ok = find_and_update_bottle_file(bottles_dir, entry.Name, key)
|
||||
if not ok:
|
||||
sys.stderr.write(
|
||||
f"git-gate: {label}: could not write to {target_file}; "
|
||||
f"host_key kept in memory for this session only\n"
|
||||
)
|
||||
else:
|
||||
sys.stderr.write(
|
||||
f"git-gate: {label}: no bottle config file found for "
|
||||
f"{entry.Name!r}; host_key kept in memory for this session only\n"
|
||||
)
|
||||
|
||||
idx = next(i for i, e in enumerate(updated_entries) if e.Name == entry.Name)
|
||||
updated_entries[idx] = dataclasses.replace(entry, KnownHostKey=key)
|
||||
|
||||
updated_bottle = dataclasses.replace(bottle, git=tuple(updated_entries))
|
||||
return dataclasses.replace(manifest, bottle=updated_bottle)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"fetch_host_key",
|
||||
"preflight_host_keys",
|
||||
"add_host_key_to_frontmatter",
|
||||
"find_repo_bottle_file",
|
||||
"find_and_update_bottle_file",
|
||||
"prompt_tty",
|
||||
]
|
||||
@@ -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 = ""
|
||||
@@ -0,0 +1,149 @@
|
||||
"""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 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)
|
||||
|
||||
return dataclasses.replace(plan, upstreams=tuple(updated))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"revoke_git_gate_provisioned_keys",
|
||||
"provision_git_gate_dynamic_keys",
|
||||
"_provision_dynamic_key",
|
||||
"_resolve_identity_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,
|
||||
)
|
||||
Reference in New Issue
Block a user