diff --git a/bot_bottle/backend/docker/compose.py b/bot_bottle/backend/docker/compose.py index bf83e5c..a7f850c 100644 --- a/bot_bottle/backend/docker/compose.py +++ b/bot_bottle/backend/docker/compose.py @@ -212,6 +212,11 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]: keypath, f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key", )) + if u.known_hosts_file: + volumes.append(_bind( + u.known_hosts_file, + f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts", + )) extra_map = git_gate_aggregate_extra_hosts(gp.upstreams) extra_hosts = [f"{host}:{ip}" for host, ip in sorted(extra_map.items())] diff --git a/bot_bottle/backend/smolmachines/launch.py b/bot_bottle/backend/smolmachines/launch.py index 9102d4c..d133e7c 100644 --- a/bot_bottle/backend/smolmachines/launch.py +++ b/bot_bottle/backend/smolmachines/launch.py @@ -366,6 +366,12 @@ def _bundle_launch_spec( f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key", True, )) + if u.known_hosts_file: + volumes.append(( + str(u.known_hosts_file), + f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts", + True, + )) # --- supervise -------------------------------------------- sp = plan.supervise_plan diff --git a/bot_bottle/git_gate.py b/bot_bottle/git_gate.py index 09a84dc..e3cad28 100644 --- a/bot_bottle/git_gate.py +++ b/bot_bottle/git_gate.py @@ -71,6 +71,7 @@ class GitGateUpstream: upstream_port: str identity_file: str known_host_key: str + known_hosts_file: Path = Path() extra_hosts: Mapping[str, str] = field(default_factory=_empty_str_map) @@ -408,10 +409,33 @@ class GitGate(ABC): # not via `sh`, so the script needs the x bit. docker cp # preserves source mode into the container. 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, + extra_hosts=dict(u.extra_hosts), + ) + ) return GitGatePlan( slug=slug, entrypoint_script=entrypoint, hook_script=hook, access_hook_script=access_hook, - upstreams=upstreams, + upstreams=tuple(upstreams_with_files), ) diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index b12dd83..e1167c8 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -148,6 +148,7 @@ def _plan( upstream_port="22", identity_file="/etc/hostname", known_host_key="", + known_hosts_file=STATE / "git-gate" / "upstream-known_hosts", extra_hosts={"example.com": "10.0.0.1"}, ),) routes: tuple[EgressRoute, ...] = () @@ -408,6 +409,7 @@ class TestSidecarBundleShape(unittest.TestCase): self.assertIn("/etc/pipelock.yaml", targets) self.assertIn("/etc/egress/routes.yaml", targets) self.assertIn("/git-gate-entrypoint.sh", targets) + self.assertIn("/git-gate/creds/upstream-known_hosts", targets) # supervise queue dir target = QUEUE_DIR_IN_CONTAINER self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise") for t in targets)) diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index 9c509ee..d83bec8 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -257,6 +257,35 @@ class TestPrepare(unittest.TestCase): self.assertEqual("", plan.internal_network) self.assertEqual("", plan.egress_network) + def test_prepare_writes_known_hosts_file(self): + plan = _StubGate().prepare( + fixture_with_git().bottles["dev"], "demo", self.stage + ) + upstream = plan.upstreams[0] + self.assertEqual(self.stage / "bot-bottle-known_hosts", + upstream.known_hosts_file) + self.assertEqual( + "[gitea.dideric.is]:30009 ssh-ed25519 AAAA...\n", + upstream.known_hosts_file.read_text(), + ) + self.assertEqual(0o600, os.stat(upstream.known_hosts_file).st_mode & 0o777) + + def test_prepare_skips_known_hosts_file_when_key_missing(self): + manifest = Manifest.from_json_obj({ + "bottles": {"dev": {"git": {"remotes": { + "github.com": { + "Name": "foo", + "Upstream": "ssh://git@github.com/didericis/foo.git", + "IdentityFile": "/dev/null", + }, + }}}}, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }) + plan = _StubGate().prepare( + manifest.bottles["dev"], "demo", self.stage + ) + self.assertEqual(Path(), plan.upstreams[0].known_hosts_file) + def test_prepare_with_no_git_writes_minimal_script(self): plan = _StubGate().prepare( fixture_minimal().bottles["dev"], "demo", self.stage