Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0b35b5506 | |||
| ea0c070cfe |
@@ -46,6 +46,7 @@ from .git_gate_render import (
|
||||
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,
|
||||
@@ -155,6 +156,7 @@ __all__ = [
|
||||
"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",
|
||||
|
||||
@@ -9,6 +9,7 @@ own; `git_gate` re-exports these names for API stability."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -125,22 +126,18 @@ def git_gate_known_hosts_line(host: str, port: str, key: str) -> str:
|
||||
return f"{target} {key}\n"
|
||||
|
||||
|
||||
def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
|
||||
"""Posix-sh entrypoint. One `init_repo` call per upstream, then
|
||||
`exec git daemon`. The function reads
|
||||
`/git-gate/creds/<name>-{key,known_hosts}` (bind-mounted into
|
||||
the bundle by the renderer) and wires them into each bare repo's
|
||||
config; the access-hook + pre-receive hook pick those paths up
|
||||
at fetch / push time."""
|
||||
lines = [
|
||||
"#!/bin/sh",
|
||||
"set -eu",
|
||||
"",
|
||||
def _git_gate_init_repo_fn(repo_root: str, creds_dir: str) -> list[str]:
|
||||
"""The `init_repo` shell function, parameterized by the bare-repo root
|
||||
and the per-bottle creds dir. Single source of the credential-wiring
|
||||
logic, shared by the single-tenant daemon entrypoint (`/git`,
|
||||
`/git-gate/creds`) and the consolidated per-bottle provisioning
|
||||
(`/git/<bottle_id>`, `/git-gate/creds/<bottle_id>`)."""
|
||||
return [
|
||||
"init_repo() {",
|
||||
" name=$1",
|
||||
" upstream_url=$2",
|
||||
" keyfile=/git-gate/creds/${name}-key",
|
||||
" hostsfile=/git-gate/creds/${name}-known_hosts",
|
||||
f" keyfile={creds_dir}/${{name}}-key",
|
||||
f" hostsfile={creds_dir}/${{name}}-known_hosts",
|
||||
"",
|
||||
# `|| true`: PRD 0018 chunk 3+ bind-mounts these RO from the
|
||||
# host, so chmod-syscalls fail with EROFS. The files already
|
||||
@@ -153,14 +150,14 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
|
||||
" chmod 600 \"$hostsfile\" 2>/dev/null || true",
|
||||
" fi",
|
||||
"",
|
||||
" repo=/git/${name}.git",
|
||||
f" repo={repo_root}/${{name}}.git",
|
||||
" if [ ! -d \"$repo\" ]; then",
|
||||
" git init --bare \"$repo\" >/dev/null",
|
||||
# --mirror=fetch sets remote.origin.fetch = +refs/*:refs/* so",
|
||||
# a later `git fetch origin` mirrors the upstream's full ref",
|
||||
# graph (heads, tags, notes) into the bare repo at canonical",
|
||||
# paths. It does NOT set remote.origin.mirror=true, so an",
|
||||
# explicit `git push origin <ref>:<ref>` still pushes one ref.",
|
||||
# --mirror=fetch sets remote.origin.fetch = +refs/*:refs/* so a later
|
||||
# `git fetch origin` mirrors the upstream's full ref graph (heads,
|
||||
# tags, notes) into the bare repo at canonical paths. It does NOT set
|
||||
# remote.origin.mirror=true, so an explicit `git push origin
|
||||
# <ref>:<ref>` still pushes one ref.
|
||||
" git -C \"$repo\" remote add --mirror=fetch origin \"$upstream_url\"",
|
||||
" fi",
|
||||
" git -C \"$repo\" config git-gate.identityFile \"$keyfile\"",
|
||||
@@ -170,9 +167,19 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
|
||||
" git -C \"$repo\" config http.receivepack true",
|
||||
" install -m 755 /etc/git-gate/pre-receive \"$repo/hooks/pre-receive\"",
|
||||
"}",
|
||||
"",
|
||||
"mkdir -p /git",
|
||||
]
|
||||
|
||||
|
||||
def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
|
||||
"""Posix-sh entrypoint. One `init_repo` call per upstream, then
|
||||
`exec git daemon`. The function reads
|
||||
`/git-gate/creds/<name>-{key,known_hosts}` (bind-mounted into
|
||||
the bundle by the renderer) and wires them into each bare repo's
|
||||
config; the access-hook + pre-receive hook pick those paths up
|
||||
at fetch / push time."""
|
||||
lines = ["#!/bin/sh", "set -eu", ""]
|
||||
lines += _git_gate_init_repo_fn("/git", "/git-gate/creds")
|
||||
lines += ["", "mkdir -p /git"]
|
||||
for u in upstreams:
|
||||
lines.append(f"init_repo {shlex.quote(u.name)} {shlex.quote(u.upstream_url)}")
|
||||
lines.extend([
|
||||
@@ -190,6 +197,35 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
# A bottle id namespaces the consolidated gateway's repo + creds dirs; it is
|
||||
# embedded unquoted in the provisioning script, so restrict it to a shell- and
|
||||
# path-safe alphabet (registry ids are token_hex — this is defense in depth).
|
||||
_SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+")
|
||||
|
||||
|
||||
def git_gate_render_provision(
|
||||
bottle_id: str, upstreams: tuple[GitGateUpstream, ...],
|
||||
) -> str:
|
||||
"""Posix-sh script that provisions ONE bottle's bare repos into the
|
||||
consolidated gateway (PRD 0070), under `/git/<bottle_id>/` with creds
|
||||
read from `/git-gate/creds/<bottle_id>/`. Init-only — no `git daemon`,
|
||||
since the shared gateway already serves every bottle; run inside the
|
||||
running gateway when the bottle is registered.
|
||||
|
||||
Isolating each bottle's repo root and creds dir by id is what keeps one
|
||||
bottle's push credentials out of another's repos on the shared gateway."""
|
||||
if not _SAFE_BOTTLE_ID.fullmatch(bottle_id):
|
||||
raise ValueError(f"git-gate: unsafe bottle id {bottle_id!r}")
|
||||
repo_root = f"/git/{bottle_id}"
|
||||
creds_dir = f"/git-gate/creds/{bottle_id}"
|
||||
lines = ["#!/bin/sh", "set -eu", ""]
|
||||
lines += _git_gate_init_repo_fn(repo_root, creds_dir)
|
||||
lines += ["", f"mkdir -p {shlex.quote(repo_root)}"]
|
||||
for u in upstreams:
|
||||
lines.append(f"init_repo {shlex.quote(u.name)} {shlex.quote(u.upstream_url)}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def git_gate_render_hook() -> str:
|
||||
"""The shared pre-receive hook: gitleaks-scan all incoming refs,
|
||||
then forward each accepted ref to the real upstream (`origin`)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Consolidated registration inputs (PRD 0070, docker slice).
|
||||
|
||||
Bridges the existing per-bottle `prepare` output to the consolidated
|
||||
registry: turns a prepared bottle's egress plan into the backend-neutral
|
||||
inputs `Orchestrator.launch_bottle` takes — the egress **policy** blob and
|
||||
launch **metadata**.
|
||||
|
||||
The policy blob is the exact routes YAML the per-bottle egress sidecar used
|
||||
to read from a file; in the consolidated model the multi-tenant gateway's
|
||||
`PolicyResolver` fetches it from the registry per request (keyed by source
|
||||
IP) instead. Same render, so consolidated and single-tenant egress apply
|
||||
byte-identical policy — a bottle's allow-list doesn't change when it moves
|
||||
onto the shared gateway.
|
||||
|
||||
Host-side glue (imports `bot_bottle.egress`), used by the launch path — not
|
||||
by the lean orchestrator control-plane process itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..egress import EgressPlan, egress_render_routes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegistrationInputs:
|
||||
"""What `Orchestrator.launch_bottle` needs to register a bottle, derived
|
||||
from its prepared plan. `policy` is served verbatim by the gateway's
|
||||
`/resolve`; `metadata` is opaque forward-compat state — it carries the
|
||||
human slug so the console / supervise can show a name, not just the
|
||||
minted bottle id."""
|
||||
|
||||
policy: str
|
||||
metadata: str
|
||||
|
||||
|
||||
def egress_policy(plan: EgressPlan) -> str:
|
||||
"""The bottle's egress policy blob: the routes YAML the gateway serves
|
||||
and the addon parses with `load_config`. Identical to the per-bottle
|
||||
`routes.yaml` render, so the consolidated path applies the same
|
||||
allow-list."""
|
||||
return egress_render_routes(plan.routes, log=plan.log)
|
||||
|
||||
|
||||
def registration_inputs(plan: EgressPlan) -> RegistrationInputs:
|
||||
"""Assemble the orchestrator registration inputs from a prepared egress
|
||||
plan. `metadata` records the slug so the shared registry can map a minted
|
||||
bottle id back to its human name."""
|
||||
return RegistrationInputs(
|
||||
policy=egress_policy(plan),
|
||||
metadata=json.dumps({"slug": plan.slug}),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["RegistrationInputs", "egress_policy", "registration_inputs"]
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Unit: consolidated per-bottle git-gate provisioning render (PRD 0070)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from bot_bottle.git_gate_render import (
|
||||
GitGateUpstream,
|
||||
git_gate_render_entrypoint,
|
||||
git_gate_render_provision,
|
||||
)
|
||||
|
||||
|
||||
def _ups(*names: str) -> tuple[GitGateUpstream, ...]:
|
||||
return tuple(
|
||||
GitGateUpstream(
|
||||
name=n,
|
||||
upstream_url=f"ssh://git@github.com/x/{n}.git",
|
||||
upstream_host="github.com",
|
||||
upstream_port="22",
|
||||
identity_file="",
|
||||
known_host_key="",
|
||||
)
|
||||
for n in names
|
||||
)
|
||||
|
||||
|
||||
class TestProvisionRender(unittest.TestCase):
|
||||
def test_namespaces_repos_and_creds_by_bottle_id(self) -> None:
|
||||
script = git_gate_render_provision("bottleab12", _ups("foo"))
|
||||
self.assertIn("repo=/git/bottleab12/${name}.git", script)
|
||||
self.assertIn("keyfile=/git-gate/creds/bottleab12/${name}-key", script)
|
||||
self.assertIn("mkdir -p /git/bottleab12", script)
|
||||
|
||||
def test_one_init_repo_call_per_upstream(self) -> None:
|
||||
script = git_gate_render_provision("b1", _ups("foo", "bar"))
|
||||
calls = [l for l in script.splitlines() if l.startswith("init_repo ")]
|
||||
self.assertEqual(2, len(calls))
|
||||
|
||||
def test_provision_does_not_start_the_daemon(self) -> None:
|
||||
# The shared gateway already serves; provisioning is init-only.
|
||||
self.assertNotIn("git daemon", git_gate_render_provision("b1", _ups("foo")))
|
||||
|
||||
def test_installs_pre_receive_hook(self) -> None:
|
||||
script = git_gate_render_provision("b1", _ups("foo"))
|
||||
self.assertIn("install -m 755 /etc/git-gate/pre-receive", script)
|
||||
|
||||
def test_rejects_unsafe_bottle_id(self) -> None:
|
||||
for bad in ("../etc", "a/b", "a b", "a;rm", ""):
|
||||
with self.assertRaises(ValueError):
|
||||
git_gate_render_provision(bad, _ups("foo"))
|
||||
|
||||
|
||||
class TestEntrypointUnchanged(unittest.TestCase):
|
||||
"""The shared `_git_gate_init_repo_fn` refactor must not alter the
|
||||
single-tenant daemon entrypoint's output."""
|
||||
|
||||
def test_entrypoint_still_single_tenant_flat(self) -> None:
|
||||
script = git_gate_render_entrypoint(_ups("foo"))
|
||||
self.assertIn("repo=/git/${name}.git", script) # flat, not namespaced
|
||||
self.assertIn("keyfile=/git-gate/creds/${name}-key", script)
|
||||
self.assertIn("--base-path=/git", script)
|
||||
self.assertIn("exec git daemon", script)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Unit: consolidated registration inputs — egress policy round-trip (PRD 0070)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.egress import EgressPlan, EgressRoute
|
||||
from bot_bottle.egress_addon_core import LOG_BLOCKS, load_config
|
||||
from bot_bottle.orchestrator.registration import (
|
||||
RegistrationInputs,
|
||||
egress_policy,
|
||||
registration_inputs,
|
||||
)
|
||||
|
||||
|
||||
def _plan(routes: tuple[EgressRoute, ...], *, slug: str = "demo", log: int = 0) -> EgressPlan:
|
||||
return EgressPlan(
|
||||
slug=slug,
|
||||
routes_path=Path("/unused/routes.yaml"),
|
||||
routes=routes,
|
||||
token_env_map={},
|
||||
log=log,
|
||||
)
|
||||
|
||||
|
||||
class TestEgressPolicy(unittest.TestCase):
|
||||
def test_policy_round_trips_through_load_config(self) -> None:
|
||||
# The policy the gateway serves must parse back to the same allow-list
|
||||
# the per-bottle sidecar applied — moving onto the shared gateway must
|
||||
# not change a bottle's egress.
|
||||
routes = (EgressRoute(host="api.example.com"), EgressRoute(host="pypi.org"))
|
||||
cfg = load_config(egress_policy(_plan(routes)))
|
||||
self.assertEqual(("api.example.com", "pypi.org"), tuple(r.host for r in cfg.routes))
|
||||
|
||||
def test_policy_preserves_log_level(self) -> None:
|
||||
plan = _plan((EgressRoute(host="x.example.com"),), log=LOG_BLOCKS)
|
||||
self.assertEqual(LOG_BLOCKS, load_config(egress_policy(plan)).log)
|
||||
|
||||
def test_empty_routes_yield_deny_all(self) -> None:
|
||||
cfg = load_config(egress_policy(_plan(())))
|
||||
self.assertEqual((), cfg.routes) # no routes → default-deny
|
||||
|
||||
|
||||
class TestRegistrationInputs(unittest.TestCase):
|
||||
def test_bundles_policy_and_slug_metadata(self) -> None:
|
||||
plan = _plan((EgressRoute(host="api.example.com"),), slug="my-bot")
|
||||
inputs = registration_inputs(plan)
|
||||
self.assertIsInstance(inputs, RegistrationInputs)
|
||||
self.assertEqual("my-bot", json.loads(inputs.metadata)["slug"])
|
||||
self.assertEqual(egress_policy(plan), inputs.policy) # same blob egress_policy renders
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user