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:
@@ -8,7 +8,12 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
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 ...log import die, info, warn
|
||||||
from ...util import expand_tilde
|
from ...util import expand_tilde
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
@@ -101,8 +106,10 @@ class DockerGitGate(GitGate):
|
|||||||
"docker", "create",
|
"docker", "create",
|
||||||
"--name", name,
|
"--name", name,
|
||||||
"--network", plan.internal_network,
|
"--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(
|
if subprocess.run(
|
||||||
create_args,
|
create_args,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
|
|||||||
@@ -30,12 +30,18 @@ backend-specific and lives on concrete subclasses (see
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from .log import die
|
||||||
from .manifest import Bottle
|
from .manifest import Bottle
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_str_map() -> dict[str, str]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class GitGateUpstream:
|
class GitGateUpstream:
|
||||||
"""One bare repo on the gate. `name` drives the bare-repo path
|
"""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
|
`identity_file` is the host-side absolute path the gate's start
|
||||||
step will docker-cp into the container. `known_host_key` is the
|
step will docker-cp into the container. `known_host_key` is the
|
||||||
KnownHostKey string from the manifest; the gate's start step
|
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
|
name: str
|
||||||
upstream_url: str
|
upstream_url: str
|
||||||
@@ -55,6 +66,7 @@ class GitGateUpstream:
|
|||||||
upstream_port: str
|
upstream_port: str
|
||||||
identity_file: str
|
identity_file: str
|
||||||
known_host_key: str
|
known_host_key: str
|
||||||
|
extra_hosts: Mapping[str, str] = field(default_factory=_empty_str_map)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -92,11 +104,38 @@ def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...]
|
|||||||
upstream_port=e.UpstreamPort,
|
upstream_port=e.UpstreamPort,
|
||||||
identity_file=e.IdentityFile,
|
identity_file=e.IdentityFile,
|
||||||
known_host_key=e.KnownHostKey,
|
known_host_key=e.KnownHostKey,
|
||||||
|
extra_hosts=dict(e.ExtraHosts),
|
||||||
)
|
)
|
||||||
for e in bottle.git
|
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:
|
def git_gate_known_hosts_line(host: str, port: str, key: str) -> str:
|
||||||
"""Format `host[:port] key` for OpenSSH's known_hosts. Non-default
|
"""Format `host[:port] key` for OpenSSH's known_hosts. Non-default
|
||||||
ports use the bracketed `[host]:port` form (the form OpenSSH writes
|
ports use the bracketed `[host]:port` form (the form OpenSSH writes
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ from claude_bottle.git_gate import (
|
|||||||
GitGate,
|
GitGate,
|
||||||
GitGatePlan,
|
GitGatePlan,
|
||||||
GitGateUpstream,
|
GitGateUpstream,
|
||||||
|
git_gate_aggregate_extra_hosts,
|
||||||
git_gate_known_hosts_line,
|
git_gate_known_hosts_line,
|
||||||
git_gate_render_access_hook,
|
git_gate_render_access_hook,
|
||||||
git_gate_render_entrypoint,
|
git_gate_render_entrypoint,
|
||||||
git_gate_render_hook,
|
git_gate_render_hook,
|
||||||
git_gate_upstreams_for_bottle,
|
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
|
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))
|
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):
|
class TestKnownHostsLine(unittest.TestCase):
|
||||||
def test_default_port_unbracketed(self):
|
def test_default_port_unbracketed(self):
|
||||||
line = git_gate_known_hosts_line("github.com", "22", "ssh-ed25519 AAAA")
|
line = git_gate_known_hosts_line("github.com", "22", "ssh-ed25519 AAAA")
|
||||||
|
|||||||
Reference in New Issue
Block a user