feat(manifest): add ExtraHosts to bottle.git entries
Optional `ExtraHosts: { hostname: ip }` map per git entry. The
docker backend will surface these to the gate sidecar via
--add-host so the gate can resolve upstreams whose default
container DNS doesn't point at the reachable IP (e.g.
Tailscale-only hosts with a public DNS A record pointed
elsewhere). The agent-side insteadOf rewrite still keys off
the original hostname, so the manifest's Upstream URL stays
human-readable.
This commit is contained in:
@@ -89,6 +89,13 @@ class GitEntry:
|
|||||||
upstream after gitleaks passes. The agent itself never holds the
|
upstream after gitleaks passes. The agent itself never holds the
|
||||||
upstream credential.
|
upstream credential.
|
||||||
|
|
||||||
|
`ExtraHosts` is an optional `{hostname: ip}` map injected into the
|
||||||
|
gate container's `/etc/hosts` via `--add-host`. Use it when the
|
||||||
|
Upstream's hostname isn't resolvable from the gate (e.g. a
|
||||||
|
Tailscale-only host whose public DNS A record points elsewhere):
|
||||||
|
the agent's `insteadOf` rewrite still matches the original
|
||||||
|
hostname, but the gate routes to the right IP.
|
||||||
|
|
||||||
The Upstream URL is parsed once at construction and the pieces are
|
The Upstream URL is parsed once at construction and the pieces are
|
||||||
stashed in the `Upstream*` fields so the git-gate render step
|
stashed in the `Upstream*` fields so the git-gate render step
|
||||||
doesn't have to re-parse."""
|
doesn't have to re-parse."""
|
||||||
@@ -97,6 +104,7 @@ class GitEntry:
|
|||||||
Upstream: str
|
Upstream: str
|
||||||
IdentityFile: str
|
IdentityFile: str
|
||||||
KnownHostKey: str = ""
|
KnownHostKey: str = ""
|
||||||
|
ExtraHosts: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
||||||
UpstreamUser: str = ""
|
UpstreamUser: str = ""
|
||||||
UpstreamHost: str = ""
|
UpstreamHost: str = ""
|
||||||
UpstreamPort: str = ""
|
UpstreamPort: str = ""
|
||||||
@@ -124,6 +132,9 @@ class GitEntry:
|
|||||||
d.get("KnownHostKey"),
|
d.get("KnownHostKey"),
|
||||||
f"bottle '{bottle_name}' git '{name}' KnownHostKey",
|
f"bottle '{bottle_name}' git '{name}' KnownHostKey",
|
||||||
)
|
)
|
||||||
|
extra_hosts = _opt_extra_hosts(
|
||||||
|
d.get("ExtraHosts"), f"bottle '{bottle_name}' git '{name}' ExtraHosts"
|
||||||
|
)
|
||||||
user, host, port, path = _parse_git_upstream(
|
user, host, port, path = _parse_git_upstream(
|
||||||
upstream, f"bottle '{bottle_name}' git '{name}' Upstream"
|
upstream, f"bottle '{bottle_name}' git '{name}' Upstream"
|
||||||
)
|
)
|
||||||
@@ -132,6 +143,7 @@ class GitEntry:
|
|||||||
Upstream=upstream,
|
Upstream=upstream,
|
||||||
IdentityFile=ident,
|
IdentityFile=ident,
|
||||||
KnownHostKey=khk,
|
KnownHostKey=khk,
|
||||||
|
ExtraHosts=extra_hosts,
|
||||||
UpstreamUser=user,
|
UpstreamUser=user,
|
||||||
UpstreamHost=host,
|
UpstreamHost=host,
|
||||||
UpstreamPort=port,
|
UpstreamPort=port,
|
||||||
@@ -435,6 +447,26 @@ def _opt_port(value: object, label: str) -> str:
|
|||||||
die(f"{label} must be a string or number (was {type(value).__name__})")
|
die(f"{label} must be a string or number (was {type(value).__name__})")
|
||||||
|
|
||||||
|
|
||||||
|
def _opt_extra_hosts(value: object, label: str) -> dict[str, str]:
|
||||||
|
"""Validate a `{hostname: ip}` object and return a plain dict. None
|
||||||
|
yields an empty dict so callers can treat ExtraHosts as always
|
||||||
|
present. IP format is not checked here; docker validates at
|
||||||
|
`--add-host` time."""
|
||||||
|
if value is None:
|
||||||
|
return {}
|
||||||
|
obj = _as_json_object(value, label)
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for host, ip in obj.items():
|
||||||
|
if not host:
|
||||||
|
die(f"{label} contains an empty hostname key")
|
||||||
|
if not isinstance(ip, str):
|
||||||
|
die(f"{label}['{host}'] must be a string (was {type(ip).__name__})")
|
||||||
|
if not ip:
|
||||||
|
die(f"{label}['{host}'] must be a non-empty string")
|
||||||
|
out[host] = ip
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
|
def _parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
|
||||||
"""Parse `ssh://user@host[:port]/path` into (user, host, port, path).
|
"""Parse `ssh://user@host[:port]/path` into (user, host, port, path).
|
||||||
Dies if `url` doesn't match the ssh:// shape v1 supports. Default
|
Dies if `url` doesn't match the ssh:// shape v1 supports. Default
|
||||||
|
|||||||
@@ -111,6 +111,53 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
}]))
|
}]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitEntryExtraHosts(unittest.TestCase):
|
||||||
|
def test_extra_hosts_defaults_to_empty(self):
|
||||||
|
m = Manifest.from_json_obj(_manifest([{
|
||||||
|
"Name": "foo",
|
||||||
|
"Upstream": "ssh://git@github.com/foo.git",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
}]))
|
||||||
|
self.assertEqual({}, dict(m.bottles["dev"].git[0].ExtraHosts))
|
||||||
|
|
||||||
|
def test_extra_hosts_parses_host_to_ip_map(self):
|
||||||
|
m = Manifest.from_json_obj(_manifest([{
|
||||||
|
"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"},
|
||||||
|
}]))
|
||||||
|
eh = dict(m.bottles["dev"].git[0].ExtraHosts)
|
||||||
|
self.assertEqual({"gitea.dideric.is": "100.78.141.42"}, eh)
|
||||||
|
|
||||||
|
def test_extra_hosts_must_be_object(self):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
Manifest.from_json_obj(_manifest([{
|
||||||
|
"Name": "foo",
|
||||||
|
"Upstream": "ssh://git@github.com/foo.git",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
"ExtraHosts": ["gitea.dideric.is", "100.78.141.42"],
|
||||||
|
}]))
|
||||||
|
|
||||||
|
def test_extra_hosts_ip_must_be_string(self):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
Manifest.from_json_obj(_manifest([{
|
||||||
|
"Name": "foo",
|
||||||
|
"Upstream": "ssh://git@github.com/foo.git",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
"ExtraHosts": {"gitea.dideric.is": 100},
|
||||||
|
}]))
|
||||||
|
|
||||||
|
def test_extra_hosts_empty_ip_dies(self):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
Manifest.from_json_obj(_manifest([{
|
||||||
|
"Name": "foo",
|
||||||
|
"Upstream": "ssh://git@github.com/foo.git",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
"ExtraHosts": {"gitea.dideric.is": ""},
|
||||||
|
}]))
|
||||||
|
|
||||||
|
|
||||||
class TestGitEntryCrossValidation(unittest.TestCase):
|
class TestGitEntryCrossValidation(unittest.TestCase):
|
||||||
def test_duplicate_name_dies(self):
|
def test_duplicate_name_dies(self):
|
||||||
with self.assertRaises(Die):
|
with self.assertRaises(Die):
|
||||||
|
|||||||
Reference in New Issue
Block a user