b1850be5d1
test / unit (pull_request) Successful in 1m19s
test / integration (pull_request) Successful in 30s
test / coverage (pull_request) Successful in 1m35s
lint / lint (push) Successful in 2m35s
test / unit (push) Successful in 1m21s
test / integration (push) Successful in 29s
test / coverage (push) Successful in 1m31s
Update Quality Badges / update-badges (push) Successful in 1m21s
Cloning/fetching from the git-gate on the Apple-container backend failed with
"empty reply from server" (curl exit 52). Root cause: the git-http handler
crashed on every upload-pack with
PermissionError: [Errno 13] Permission denied: '/etc/git-gate/access-hook'
The access-hook is exec'd directly, so it needs the x bit. prepare() stages it
0o700 and trusted the gateway copy to carry that mode. `docker cp` does; the
Apple `container cp` (AppleGatewayTransport) does not, landing the hook 0o644 →
EACCES. The unhandled exception killed the handler thread, closing the socket
with no HTTP response — which the client sees as the opaque empty reply.
- provision_git_gate now `chmod +x`es the access-hook on the gateway side after
the copy, so it's executable under every transport (docker/apple/firecracker).
- git-http handler wraps the access-hook subprocess.run: an OSError /
SubprocessError (un-execable, timed out) now fails closed with a 503 instead
of crashing the thread into an empty reply — a gate that can't run its hook
should deny, visibly.
- Updates the now-misleading "docker cp preserves source mode" comment in
git_gate.prepare().
Regression tests: provisioning applies +x to the access-hook; the handler
returns 503 (not an empty reply) when the hook can't be exec'd.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
5.4 KiB
Python
133 lines
5.4 KiB
Python
"""Provision one bottle's git-gate state into the running shared gateway
|
|
(PRD 0070, docker slice).
|
|
|
|
The consolidated gateway serves every bottle's repos under `/git/<bottle_id>/`
|
|
with per-repo credentials under `/git-gate/creds/<bottle_id>/`. When a bottle
|
|
is registered the launcher must place *its* deploy keys + known_hosts into
|
|
that per-bottle creds dir and init its bare repos there — so this copies the
|
|
credential files into the live gateway container and runs the (namespaced,
|
|
init-only) provisioning script produced by `git_gate_render_provision`.
|
|
|
|
Isolating each bottle's creds dir + repo root by id is what keeps one
|
|
bottle's push credentials out of another's repos on the shared gateway.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Protocol
|
|
|
|
from ...docker_cmd import run_docker
|
|
from ...git_gate import GitGatePlan, git_gate_render_provision
|
|
|
|
# bottle ids index the gateway's per-bottle repo + creds dirs; they land in
|
|
# exec/cp path arguments, so validate before any path is built (a traversal
|
|
# id like "../etc" must never reach the gateway). Registry ids are token_hex —
|
|
# this is defense in depth at the transport boundary.
|
|
_SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+")
|
|
|
|
|
|
class GatewayProvisionError(RuntimeError):
|
|
"""A git-gate provisioning step against the running gateway failed."""
|
|
|
|
|
|
class GatewayTransport(Protocol):
|
|
"""How the launcher stages files + runs commands in the running gateway.
|
|
Backend-neutral so the same provisioning logic serves the docker gateway
|
|
(exec/cp over the docker socket) and the firecracker gateway VM (over
|
|
SSH)."""
|
|
|
|
def exec(self, argv: list[str]) -> None:
|
|
"""Run `argv` in the gateway, raising `GatewayProvisionError` on
|
|
failure."""
|
|
|
|
def cp_into(self, src: str, dest: str) -> None:
|
|
"""Copy host file `src` to `dest` in the gateway, raising on
|
|
failure."""
|
|
|
|
|
|
class DockerGatewayTransport:
|
|
"""`GatewayTransport` for the docker gateway container (exec/cp)."""
|
|
|
|
def __init__(self, gateway: str) -> None:
|
|
self.gateway = gateway
|
|
|
|
def exec(self, argv: list[str]) -> None:
|
|
proc = run_docker(["docker", "exec", self.gateway, *argv])
|
|
if proc.returncode != 0:
|
|
raise GatewayProvisionError(
|
|
f"gateway exec {argv!r} failed: {proc.stderr.strip()}"
|
|
)
|
|
|
|
def cp_into(self, src: str, dest: str) -> None:
|
|
proc = run_docker(["docker", "cp", src, f"{self.gateway}:{dest}"])
|
|
if proc.returncode != 0:
|
|
raise GatewayProvisionError(
|
|
f"gateway cp {src} -> {dest} failed: {proc.stderr.strip()}"
|
|
)
|
|
|
|
|
|
def _require_safe(bottle_id: str) -> None:
|
|
if not _SAFE_BOTTLE_ID.fullmatch(bottle_id):
|
|
raise GatewayProvisionError(f"unsafe bottle id {bottle_id!r}")
|
|
|
|
|
|
def _creds_dir(bottle_id: str) -> str:
|
|
return f"/git-gate/creds/{bottle_id}"
|
|
|
|
|
|
def provision_git_gate(
|
|
transport: GatewayTransport, bottle_id: str, plan: GitGatePlan,
|
|
) -> None:
|
|
"""Place `bottle_id`'s git-gate credentials into the running gateway and
|
|
init its bare repos under `/git/<bottle_id>/`.
|
|
|
|
Copies each upstream's identity key (and known_hosts, when present) into
|
|
`/git-gate/creds/<bottle_id>/`, then runs the namespaced provisioning
|
|
script. No-op for a bottle with no git upstreams."""
|
|
_require_safe(bottle_id)
|
|
if not plan.upstreams:
|
|
return
|
|
# The pre-receive + access hooks are bottle-agnostic and shared by every
|
|
# bottle's repos; install them into the gateway (idempotent — same content
|
|
# each time). The per-bottle model cp'd these into each bundle at start.
|
|
transport.exec(["mkdir", "-p", "/etc/git-gate"])
|
|
transport.cp_into(str(plan.hook_script), "/etc/git-gate/pre-receive")
|
|
transport.cp_into(str(plan.access_hook_script), "/etc/git-gate/access-hook")
|
|
# The access-hook is exec'd directly (not via `sh`), so it needs the x bit.
|
|
# Set it here rather than trusting the copy to carry the staged 0o700:
|
|
# `docker cp` preserves source mode, but the Apple `container cp` does not,
|
|
# landing the hook 0o644 → EACCES when the git-http handler tries to exec it.
|
|
# chmod on the gateway side is backend-neutral and fixes every transport.
|
|
transport.exec(["chmod", "+x", "/etc/git-gate/access-hook"])
|
|
creds = _creds_dir(bottle_id)
|
|
transport.exec(["mkdir", "-p", creds])
|
|
for u in plan.upstreams:
|
|
if u.identity_file:
|
|
transport.cp_into(u.identity_file, f"{creds}/{u.name}-key")
|
|
known_hosts = str(u.known_hosts_file)
|
|
if known_hosts and known_hosts != ".":
|
|
transport.cp_into(known_hosts, f"{creds}/{u.name}-known_hosts")
|
|
# Init the bare repos + per-repo credential config for this namespace.
|
|
script = git_gate_render_provision(bottle_id, plan.upstreams)
|
|
transport.exec(["sh", "-c", script])
|
|
|
|
|
|
def deprovision_git_gate(transport: GatewayTransport, bottle_id: str) -> None:
|
|
"""Remove a bottle's repos + creds from the gateway on teardown. Idempotent
|
|
— an already-absent namespace is a clean no-op (best effort; a stray dir
|
|
can't leak, since attribution is by source IP and the bottle is gone)."""
|
|
_require_safe(bottle_id)
|
|
try:
|
|
transport.exec([
|
|
"rm", "-rf", f"/git/{bottle_id}", _creds_dir(bottle_id),
|
|
])
|
|
except GatewayProvisionError:
|
|
pass # best-effort teardown; absent namespace is success
|
|
|
|
|
|
__all__ = [
|
|
"provision_git_gate", "deprovision_git_gate",
|
|
"GatewayProvisionError", "GatewayTransport", "DockerGatewayTransport",
|
|
]
|