From 102e29ee774b54d23959d05a9265a3b32cb0dd2e Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 12 May 2026 23:06:08 -0400 Subject: [PATCH] feat(git-gate): plumb ExtraHosts through to docker --add-host GitGateUpstream carries each entry's extra_hosts; a new git_gate_aggregate_extra_hosts() merges them into one map for the gate container's /etc/hosts. Same host -> same IP is harmless duplication; same host -> different IPs is a manifest bug (/etc/hosts is per-container, not per-upstream) and dies with the conflicting upstream names. DockerGitGate.start passes one --add-host host:ip per merged entry on docker create. Empty map (the default) emits no flags and is a no-op for bottles that don't need DNS overrides. --- claude_bottle/backend/docker/git_gate.py | 11 +++- claude_bottle/git_gate.py | 43 ++++++++++++- tests/unit/test_git_gate.py | 81 ++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/claude_bottle/backend/docker/git_gate.py b/claude_bottle/backend/docker/git_gate.py index e3c11a3..5ad312c 100644 --- a/claude_bottle/backend/docker/git_gate.py +++ b/claude_bottle/backend/docker/git_gate.py @@ -8,7 +8,12 @@ import os import subprocess from pathlib import Path -from ...git_gate import GitGate, GitGatePlan, git_gate_known_hosts_line +from ...git_gate import ( + GitGate, + GitGatePlan, + git_gate_aggregate_extra_hosts, + git_gate_known_hosts_line, +) from ...log import die, info, warn from ...util import expand_tilde from . import util as docker_mod @@ -101,8 +106,10 @@ class DockerGitGate(GitGate): "docker", "create", "--name", name, "--network", plan.internal_network, - GIT_GATE_IMAGE, ] + for host, ip in git_gate_aggregate_extra_hosts(plan.upstreams).items(): + create_args.extend(["--add-host", f"{host}:{ip}"]) + create_args.append(GIT_GATE_IMAGE) if subprocess.run( create_args, stdout=subprocess.DEVNULL, diff --git a/claude_bottle/git_gate.py b/claude_bottle/git_gate.py index 6827178..e95c63c 100644 --- a/claude_bottle/git_gate.py +++ b/claude_bottle/git_gate.py @@ -30,12 +30,18 @@ backend-specific and lives on concrete subclasses (see from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path +from typing import Mapping +from .log import die from .manifest import Bottle +def _empty_str_map() -> dict[str, str]: + return {} + + @dataclass(frozen=True) class GitGateUpstream: """One bare repo on the gate. `name` drives the bare-repo path @@ -47,7 +53,12 @@ class GitGateUpstream: `identity_file` is the host-side absolute path the gate's start step will docker-cp into the container. `known_host_key` is the KnownHostKey string from the manifest; the gate's start step - materialises it into a known_hosts file if non-empty.""" + materialises it into a known_hosts file if non-empty. + + `extra_hosts` is a `{hostname: ip}` map the backend injects into + the gate container's `/etc/hosts` via `--add-host` so the gate + can resolve upstream hostnames that aren't reachable via the + container's default DNS (e.g. Tailscale-only hosts).""" name: str upstream_url: str @@ -55,6 +66,7 @@ class GitGateUpstream: upstream_port: str identity_file: str known_host_key: str + extra_hosts: Mapping[str, str] = field(default_factory=_empty_str_map) @dataclass(frozen=True) @@ -92,11 +104,38 @@ def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...] upstream_port=e.UpstreamPort, identity_file=e.IdentityFile, known_host_key=e.KnownHostKey, + extra_hosts=dict(e.ExtraHosts), ) for e in bottle.git ) +def git_gate_aggregate_extra_hosts( + upstreams: tuple[GitGateUpstream, ...], +) -> dict[str, str]: + """Merge every upstream's `extra_hosts` into a single + `{hostname: ip}` map for `--add-host` on the gate container. Two + entries naming the same hostname with different IPs is a manifest + bug — the gate has one /etc/hosts — so die loudly with the + conflicting names rather than silently picking one.""" + merged: dict[str, str] = {} + source: dict[str, str] = {} + for u in upstreams: + for host, ip in u.extra_hosts.items(): + existing = merged.get(host) + if existing is None: + merged[host] = ip + source[host] = u.name + elif existing != ip: + die( + f"git-gate ExtraHosts conflict: '{host}' maps to " + f"'{existing}' in upstream '{source[host]}' and to " + f"'{ip}' in upstream '{u.name}'. The gate has one " + f"/etc/hosts; pick one IP." + ) + return merged + + def git_gate_known_hosts_line(host: str, port: str, key: str) -> str: """Format `host[:port] key` for OpenSSH's known_hosts. Non-default ports use the bracketed `[host]:port` form (the form OpenSSH writes diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index ff2d402..eabb66a 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -9,12 +9,15 @@ from claude_bottle.git_gate import ( GitGate, GitGatePlan, GitGateUpstream, + git_gate_aggregate_extra_hosts, git_gate_known_hosts_line, git_gate_render_access_hook, git_gate_render_entrypoint, git_gate_render_hook, git_gate_upstreams_for_bottle, ) +from claude_bottle.log import Die +from claude_bottle.manifest import Manifest from tests.fixtures import fixture_minimal, fixture_with_git @@ -43,6 +46,84 @@ class TestUpstreamsForBottle(unittest.TestCase): self.assertEqual((), git_gate_upstreams_for_bottle(bottle)) +class TestExtraHostsPlumbing(unittest.TestCase): + def test_upstream_carries_extra_hosts_from_manifest(self): + m = Manifest.from_json_obj({ + "bottles": { + "dev": { + "git": [{ + "Name": "claude-bottle", + "Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git", + "IdentityFile": "/dev/null", + "ExtraHosts": {"gitea.dideric.is": "100.78.141.42"}, + }], + }, + }, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }) + ups = git_gate_upstreams_for_bottle(m.bottles["dev"]) + self.assertEqual( + {"gitea.dideric.is": "100.78.141.42"}, dict(ups[0].extra_hosts) + ) + + def test_aggregator_merges_distinct_hostnames(self): + ups = ( + GitGateUpstream( + name="a", upstream_url="", upstream_host="", upstream_port="", + identity_file="", known_host_key="", + extra_hosts={"a.example": "10.0.0.1"}, + ), + GitGateUpstream( + name="b", upstream_url="", upstream_host="", upstream_port="", + identity_file="", known_host_key="", + extra_hosts={"b.example": "10.0.0.2"}, + ), + ) + self.assertEqual( + {"a.example": "10.0.0.1", "b.example": "10.0.0.2"}, + git_gate_aggregate_extra_hosts(ups), + ) + + def test_aggregator_allows_same_host_same_ip(self): + # Two entries listing the same host:ip is harmless duplication, + # not a conflict. The gate's /etc/hosts ends up with one line. + ups = ( + GitGateUpstream( + name="a", upstream_url="", upstream_host="", upstream_port="", + identity_file="", known_host_key="", + extra_hosts={"gitea.dideric.is": "100.78.141.42"}, + ), + GitGateUpstream( + name="b", upstream_url="", upstream_host="", upstream_port="", + identity_file="", known_host_key="", + extra_hosts={"gitea.dideric.is": "100.78.141.42"}, + ), + ) + self.assertEqual( + {"gitea.dideric.is": "100.78.141.42"}, + git_gate_aggregate_extra_hosts(ups), + ) + + def test_aggregator_rejects_conflicting_ips(self): + ups = ( + GitGateUpstream( + name="a", upstream_url="", upstream_host="", upstream_port="", + identity_file="", known_host_key="", + extra_hosts={"gitea.dideric.is": "100.78.141.42"}, + ), + GitGateUpstream( + name="b", upstream_url="", upstream_host="", upstream_port="", + identity_file="", known_host_key="", + extra_hosts={"gitea.dideric.is": "10.0.0.99"}, + ), + ) + with self.assertRaises(Die): + git_gate_aggregate_extra_hosts(ups) + + def test_aggregator_empty_is_empty(self): + self.assertEqual({}, git_gate_aggregate_extra_hosts(())) + + class TestKnownHostsLine(unittest.TestCase): def test_default_port_unbracketed(self): line = git_gate_known_hosts_line("github.com", "22", "ssh-ed25519 AAAA")