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.
This commit is contained in:
2026-05-12 23:06:08 -04:00
parent 4c6610e222
commit 102e29ee77
3 changed files with 131 additions and 4 deletions
+9 -2
View File
@@ -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,
+41 -2
View File
@@ -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
+81
View File
@@ -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")