git-gate: ExtraHosts on bottle.git entries #12
@@ -118,8 +118,14 @@ that enforces the manifest before it leaves the host.
|
|||||||
upstream has *now* (fail-closed if unreachable). The agent's
|
upstream has *now* (fail-closed if unreachable). The agent's
|
||||||
`~/.gitconfig` rewrites the real URL to the gate via `insteadOf`,
|
`~/.gitconfig` rewrites the real URL to the gate via `insteadOf`,
|
||||||
so push, fetch, clone, and pull all route through. The agent
|
so push, fetch, clone, and pull all route through. The agent
|
||||||
never sees the upstream credential. Brought up only when
|
never sees the upstream credential. If the upstream's hostname
|
||||||
`bottle.git` has entries. Design in `docs/prds/0008-git-gate.md`.
|
isn't resolvable from the gate container (e.g. a Tailscale-only
|
||||||
|
host whose public DNS points elsewhere), pin its IP via
|
||||||
|
`ExtraHosts: { "<hostname>": "<ip>" }` on the `bottle.git` entry —
|
||||||
|
the gate's `/etc/hosts` gets the override while the agent's
|
||||||
|
`insteadOf` rewrite still keys off the original hostname. Brought
|
||||||
|
up only when `bottle.git` has entries. Design in
|
||||||
|
`docs/prds/0008-git-gate.md`.
|
||||||
|
|
||||||
When the agent exits, `cli.py` tears down every sidecar that was
|
When the agent exits, `cli.py` tears down every sidecar that was
|
||||||
brought up and the two networks; nothing about a bottle persists
|
brought up and the two networks; nothing about a bottle persists
|
||||||
|
|||||||
@@ -19,14 +19,13 @@
|
|||||||
"GIT_AUTHOR_NAME": "Eric Diderich",
|
"GIT_AUTHOR_NAME": "Eric Diderich",
|
||||||
"NODE_ENV": "development"
|
"NODE_ENV": "development"
|
||||||
},
|
},
|
||||||
"ssh": [
|
"git": [
|
||||||
{
|
{
|
||||||
"Host": "gitea",
|
"Name": "claude-bottle",
|
||||||
"Hostname": "gitea.dideric.is",
|
"Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
|
||||||
"User": "git",
|
|
||||||
"Port": 30009,
|
|
||||||
"IdentityFile": "/Users/didericis/.ssh/id_ed25519_gitea",
|
"IdentityFile": "/Users/didericis/.ssh/id_ed25519_gitea",
|
||||||
"KnownHostKey": "gitea.dideric.is ssh-ed25519 AAAA..."
|
"KnownHostKey": "ssh-ed25519 AAAA...",
|
||||||
|
"ExtraHosts": { "gitea.dideric.is": "100.78.141.42" }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"egress": {
|
"egress": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -83,7 +83,12 @@ for a declared upstream:
|
|||||||
- **Manifest field.** `bottle.git` — a list of git remotes the
|
- **Manifest field.** `bottle.git` — a list of git remotes the
|
||||||
bottle is allowed to talk to, each with the credential the gate
|
bottle is allowed to talk to, each with the credential the gate
|
||||||
uses to push upstream. The agent gets no parallel `bottle.ssh`
|
uses to push upstream. The agent gets no parallel `bottle.ssh`
|
||||||
entry for those upstreams.
|
entry for those upstreams. Each entry may also carry an
|
||||||
|
`ExtraHosts: { hostname: ip }` map, surfaced to the gate as
|
||||||
|
`--add-host` so the gate can resolve upstreams whose public DNS
|
||||||
|
doesn't point at the reachable IP (e.g. Tailscale-only hosts).
|
||||||
|
The agent-side `insteadOf` rewrite keys off the original hostname,
|
||||||
|
so the manifest's `Upstream` URL stays human-readable.
|
||||||
- **Agent-side URL rewrite.** Provisioner emits `~/.gitconfig`
|
- **Agent-side URL rewrite.** Provisioner emits `~/.gitconfig`
|
||||||
with `[url "<gate-url>"] insteadOf = <real-url>` so every git
|
with `[url "<gate-url>"] insteadOf = <real-url>` so every git
|
||||||
operation against the declared upstream (push, fetch, clone,
|
operation against the declared upstream (push, fetch, clone,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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