feat(git-gate): wire DockerGitGate through prepare/launch/plan
DockerBottleBackend now instantiates a DockerGitGate alongside DockerPipelockProxy and DockerSSHGate; the prepare step lifts bottle.git into a GitGatePlan stored on DockerBottlePlan, and launch starts/stops the sidecar in the same ExitStack as the other two (only when bottle.git is non-empty). bottle_plan.print now surfaces git remotes and per-upstream gate forwards in the y/N preflight; to_dict adds git_remotes and git_gate keys to the dry-run JSON payload for CLI consumers. PRD: docs/prds/0008-git-gate.md
This commit is contained in:
@@ -23,6 +23,7 @@ from . import prepare as _prepare
|
|||||||
from .bottle import DockerBottle
|
from .bottle import DockerBottle
|
||||||
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
|
from .git_gate import DockerGitGate
|
||||||
from .pipelock import DockerPipelockProxy
|
from .pipelock import DockerPipelockProxy
|
||||||
from .provision import ca as _ca
|
from .provision import ca as _ca
|
||||||
from .provision import git as _git
|
from .provision import git as _git
|
||||||
@@ -41,16 +42,25 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._proxy = DockerPipelockProxy()
|
self._proxy = DockerPipelockProxy()
|
||||||
self._gate = DockerSSHGate()
|
self._gate = DockerSSHGate()
|
||||||
|
self._git_gate = DockerGitGate()
|
||||||
|
|
||||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
||||||
return _prepare.resolve_plan(
|
return _prepare.resolve_plan(
|
||||||
spec, stage_dir=stage_dir, proxy=self._proxy, gate=self._gate
|
spec,
|
||||||
|
stage_dir=stage_dir,
|
||||||
|
proxy=self._proxy,
|
||||||
|
gate=self._gate,
|
||||||
|
git_gate=self._git_gate,
|
||||||
)
|
)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
|
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
|
||||||
with _launch.launch(
|
with _launch.launch(
|
||||||
plan, proxy=self._proxy, gate=self._gate, provision=self.provision
|
plan,
|
||||||
|
proxy=self._proxy,
|
||||||
|
gate=self._gate,
|
||||||
|
git_gate=self._git_gate,
|
||||||
|
provision=self.provision,
|
||||||
) as bottle:
|
) as bottle:
|
||||||
yield bottle
|
yield bottle
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import sys
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...git_gate import GitGatePlan
|
||||||
from ...log import info
|
from ...log import info
|
||||||
from ...manifest import Agent, Bottle
|
from ...manifest import Agent, Bottle
|
||||||
from ...pipelock import PipelockProxyPlan, pipelock_effective_allowlist
|
from ...pipelock import PipelockProxyPlan, pipelock_effective_allowlist
|
||||||
@@ -27,6 +28,7 @@ class _PlanView:
|
|||||||
bottle: Bottle
|
bottle: Bottle
|
||||||
env_names: list[str]
|
env_names: list[str]
|
||||||
ssh_hosts: list[str]
|
ssh_hosts: list[str]
|
||||||
|
git_names: list[str]
|
||||||
prompt_first_line: str
|
prompt_first_line: str
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
prompt_file: Path
|
prompt_file: Path
|
||||||
proxy_plan: PipelockProxyPlan
|
proxy_plan: PipelockProxyPlan
|
||||||
gate_plan: SSHGatePlan
|
gate_plan: SSHGatePlan
|
||||||
|
git_gate_plan: GitGatePlan
|
||||||
allowlist_summary: str
|
allowlist_summary: str
|
||||||
use_runsc: bool
|
use_runsc: bool
|
||||||
|
|
||||||
@@ -67,6 +70,7 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
bottle=bottle,
|
bottle=bottle,
|
||||||
env_names=env_names,
|
env_names=env_names,
|
||||||
ssh_hosts=[e.Host for e in bottle.ssh],
|
ssh_hosts=[e.Host for e in bottle.ssh],
|
||||||
|
git_names=[e.Name for e in bottle.git],
|
||||||
prompt_first_line=agent.prompt.splitlines()[0] if agent.prompt else "",
|
prompt_first_line=agent.prompt.splitlines()[0] if agent.prompt else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,6 +104,16 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
info(f" ssh gate : {'; '.join(gate_lines)}")
|
info(f" ssh gate : {'; '.join(gate_lines)}")
|
||||||
else:
|
else:
|
||||||
info(" ssh hosts : (none)")
|
info(" ssh hosts : (none)")
|
||||||
|
if v.git_names:
|
||||||
|
info(f" git remotes : {', '.join(v.git_names)}")
|
||||||
|
git_lines = [
|
||||||
|
f"{u.name} -> {u.upstream_host}:{u.upstream_port} "
|
||||||
|
f"(gitleaks-scanned)"
|
||||||
|
for u in self.git_gate_plan.upstreams
|
||||||
|
]
|
||||||
|
info(f" git gate : {'; '.join(git_lines)}")
|
||||||
|
else:
|
||||||
|
info(" git remotes : (none)")
|
||||||
info(f" egress : {self.allowlist_summary}")
|
info(f" egress : {self.allowlist_summary}")
|
||||||
info(" tls intercept : pipelock (per-bottle ephemeral CA, generated at launch)")
|
info(" tls intercept : pipelock (per-bottle ephemeral CA, generated at launch)")
|
||||||
info(
|
info(
|
||||||
@@ -131,6 +145,16 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
}
|
}
|
||||||
for u in self.gate_plan.upstreams
|
for u in self.gate_plan.upstreams
|
||||||
],
|
],
|
||||||
|
"git_remotes": v.git_names,
|
||||||
|
"git_gate": [
|
||||||
|
{
|
||||||
|
"name": u.name,
|
||||||
|
"upstream": f"{u.upstream_host}:{u.upstream_port}",
|
||||||
|
"upstream_url": u.upstream_url,
|
||||||
|
"known_host_key_pinned": bool(u.known_host_key),
|
||||||
|
}
|
||||||
|
for u in self.git_gate_plan.upstreams
|
||||||
|
],
|
||||||
"egress": {
|
"egress": {
|
||||||
"host_count": len(hosts),
|
"host_count": len(hosts),
|
||||||
"hosts": hosts,
|
"hosts": hosts,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from . import network as network_mod
|
|||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle import DockerBottle
|
from .bottle import DockerBottle
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
|
from .git_gate import DockerGitGate
|
||||||
from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init
|
from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init
|
||||||
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||||
from .ssh_gate import DockerSSHGate
|
from .ssh_gate import DockerSSHGate
|
||||||
@@ -37,6 +38,7 @@ def launch(
|
|||||||
*,
|
*,
|
||||||
proxy: DockerPipelockProxy,
|
proxy: DockerPipelockProxy,
|
||||||
gate: DockerSSHGate,
|
gate: DockerSSHGate,
|
||||||
|
git_gate: DockerGitGate,
|
||||||
provision: Callable[[DockerBottlePlan, str], str | None],
|
provision: Callable[[DockerBottlePlan, str], str | None],
|
||||||
) -> Generator[DockerBottle, None, None]:
|
) -> Generator[DockerBottle, None, None]:
|
||||||
"""Build, launch, and provision a Docker bottle. Teardown on exit.
|
"""Build, launch, and provision a Docker bottle. Teardown on exit.
|
||||||
@@ -102,6 +104,21 @@ def launch(
|
|||||||
gate_name = gate.start(plan.gate_plan)
|
gate_name = gate.start(plan.gate_plan)
|
||||||
stack.callback(gate.stop, gate_name)
|
stack.callback(gate.stop, gate_name)
|
||||||
|
|
||||||
|
# Git gate (PRD 0008). One sidecar per agent, only brought up
|
||||||
|
# when the bottle has git entries. Same internal + egress
|
||||||
|
# network attachment as the other sidecars; agent dials it as
|
||||||
|
# `git://<container-name>/<name>.git` via the pushInsteadOf
|
||||||
|
# rules provision_git writes into ~/.gitconfig.
|
||||||
|
if plan.git_gate_plan.upstreams:
|
||||||
|
git_gate_plan = dataclasses.replace(
|
||||||
|
plan.git_gate_plan,
|
||||||
|
internal_network=internal_network,
|
||||||
|
egress_network=egress_network,
|
||||||
|
)
|
||||||
|
plan = dataclasses.replace(plan, git_gate_plan=git_gate_plan)
|
||||||
|
git_gate_name = git_gate.start(plan.git_gate_plan)
|
||||||
|
stack.callback(git_gate.stop, git_gate_name)
|
||||||
|
|
||||||
container = _run_agent_container(plan, internal_network)
|
container = _run_agent_container(plan, internal_network)
|
||||||
stack.callback(docker_mod.force_remove_container, container)
|
stack.callback(docker_mod.force_remove_container, container)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from ...log import die
|
|||||||
from .. import BottleSpec
|
from .. import BottleSpec
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
|
from .git_gate import DockerGitGate
|
||||||
from .pipelock import DockerPipelockProxy
|
from .pipelock import DockerPipelockProxy
|
||||||
from .ssh_gate import DockerSSHGate
|
from .ssh_gate import DockerSSHGate
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ def resolve_plan(
|
|||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
proxy: DockerPipelockProxy,
|
proxy: DockerPipelockProxy,
|
||||||
gate: DockerSSHGate,
|
gate: DockerSSHGate,
|
||||||
|
git_gate: DockerGitGate,
|
||||||
) -> DockerBottlePlan:
|
) -> DockerBottlePlan:
|
||||||
"""Resolve Docker-specific names and write scratch files. Trusts
|
"""Resolve Docker-specific names and write scratch files. Trusts
|
||||||
that the agent and its skills/SSH keys are present — validation
|
that the agent and its skills/SSH keys are present — validation
|
||||||
@@ -81,6 +83,7 @@ def resolve_plan(
|
|||||||
|
|
||||||
proxy_plan = proxy.prepare(bottle, slug, stage_dir)
|
proxy_plan = proxy.prepare(bottle, slug, stage_dir)
|
||||||
gate_plan = gate.prepare(bottle, slug, stage_dir)
|
gate_plan = gate.prepare(bottle, slug, stage_dir)
|
||||||
|
git_gate_plan = git_gate.prepare(bottle, slug, stage_dir)
|
||||||
resolved = resolve_env(manifest, spec.agent_name)
|
resolved = resolve_env(manifest, spec.agent_name)
|
||||||
# Everything that should reach the bottle by-name (so its value
|
# Everything that should reach the bottle by-name (so its value
|
||||||
# never lands on argv or in env_file) goes into one dict. The
|
# never lands on argv or in env_file) goes into one dict. The
|
||||||
@@ -109,6 +112,7 @@ def resolve_plan(
|
|||||||
prompt_file=prompt_file,
|
prompt_file=prompt_file,
|
||||||
proxy_plan=proxy_plan,
|
proxy_plan=proxy_plan,
|
||||||
gate_plan=gate_plan,
|
gate_plan=gate_plan,
|
||||||
|
git_gate_plan=git_gate_plan,
|
||||||
allowlist_summary=allowlist_summary,
|
allowlist_summary=allowlist_summary,
|
||||||
use_runsc=use_runsc,
|
use_runsc=use_runsc,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ class TestDryRunPlan(unittest.TestCase):
|
|||||||
self.assertEqual([], plan["skills"])
|
self.assertEqual([], plan["skills"])
|
||||||
self.assertEqual([], plan["ssh_hosts"])
|
self.assertEqual([], plan["ssh_hosts"])
|
||||||
self.assertEqual([], plan["ssh_gate"])
|
self.assertEqual([], plan["ssh_gate"])
|
||||||
|
self.assertEqual([], plan["git_remotes"])
|
||||||
|
self.assertEqual([], plan["git_gate"])
|
||||||
self.assertEqual(False, plan["remote_control"])
|
self.assertEqual(False, plan["remote_control"])
|
||||||
self.assertEqual(0, plan["prompt"]["length"])
|
self.assertEqual(0, plan["prompt"]["length"])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user