feat(manifest)!: remove SshEntry and bottle.ssh (PRD 0009)

Drop the SshEntry dataclass, the Bottle.ssh field, the shadow-
route validator, and the SSH-only _opt_port helper. A legacy
bottle.ssh key now parse-fails with a one-line hint pointing at
bottle.git (PRD 0008), which is the replacement.

BREAKING: manifests carrying bottle.ssh will not load. Migration
is per-entry: drop the ssh entry, add a git entry with a Name +
full Upstream URL + IdentityFile.
This commit is contained in:
2026-05-12 23:41:09 -04:00
parent efcafae810
commit c403d137b6
+13 -92
View File
@@ -6,7 +6,6 @@ Schema (see CLAUDE.md "Intended design"):
"bottles": {
"<bottle-name>": {
"env": { "<NAME>": <env-entry>, ... },
"ssh": [ <ssh-entry>, ... ],
"git": [ <git-entry>, ... ],
"egress": { "allowlist": [ "<hostname>", ... ] }
}
@@ -20,8 +19,9 @@ Schema (see CLAUDE.md "Intended design"):
}
}
Bottles group shared infrastructure (SSH keys, known hosts, egress allowlist)
that multiple agents can reference. Every agent must reference a bottle.
Bottles group shared infrastructure (git upstreams + their gate credentials,
egress allowlist) that multiple agents can reference. Every agent must
reference a bottle.
Validation runs once at construction (Manifest.from_json_obj) so getters
can trust the shape.
@@ -42,44 +42,6 @@ def _empty_str_dict() -> dict[str, str]:
return {}
@dataclass(frozen=True)
class SshEntry:
Host: str
IdentityFile: str
Hostname: str = ""
User: str = ""
Port: str = ""
KnownHostKey: str = ""
@classmethod
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "SshEntry":
d = _as_json_object(raw, f"bottle '{bottle_name}' ssh[{idx}]")
host = d.get("Host")
if not isinstance(host, str) or not host:
die(f"bottle '{bottle_name}' ssh[{idx}] missing required string field 'Host'")
ident = d.get("IdentityFile")
if not isinstance(ident, str) or not ident:
die(
f"bottle '{bottle_name}' ssh '{host}' missing required string field "
f"'IdentityFile'"
)
hostname = _opt_str(d.get("Hostname"), f"bottle '{bottle_name}' ssh '{host}' Hostname")
user = _opt_str(d.get("User"), f"bottle '{bottle_name}' ssh '{host}' User")
port = _opt_port(d.get("Port"), f"bottle '{bottle_name}' ssh '{host}' Port")
khk = _opt_str(
d.get("KnownHostKey"),
f"bottle '{bottle_name}' ssh '{host}' KnownHostKey",
)
return cls(
Host=host,
IdentityFile=ident,
Hostname=hostname,
User=user,
Port=port,
KnownHostKey=khk,
)
@dataclass(frozen=True)
class GitEntry:
"""One upstream the per-agent git-gate (PRD 0008) is allowed to
@@ -205,7 +167,6 @@ class BottleEgress:
@dataclass(frozen=True)
class Bottle:
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
ssh: tuple[SshEntry, ...] = ()
git: tuple[GitEntry, ...] = ()
egress: BottleEgress = field(default_factory=BottleEgress)
@@ -221,6 +182,15 @@ class Bottle:
f"definition."
)
if "ssh" in d:
die(
f"bottle '{name}' has an 'ssh' field, which has been removed "
f"(PRD 0009). Move each entry to 'git': declare the upstream "
f"as a git remote with Name + Upstream URL + IdentityFile, "
f"and the per-bottle git-gate (PRD 0008) will hold the "
f"credential and gitleaks-scan pushes."
)
env: dict[str, str] = {}
env_raw = d.get("env")
if env_raw is not None:
@@ -233,17 +203,6 @@ class Bottle:
)
env[var] = value
ssh: tuple[SshEntry, ...] = ()
ssh_raw = d.get("ssh")
if ssh_raw is not None:
if not isinstance(ssh_raw, list):
die(f"bottle '{name}' ssh must be an array (was {type(ssh_raw).__name__})")
ssh_list = cast(list[object], ssh_raw)
ssh = tuple(
SshEntry.from_dict(name, i, entry)
for i, entry in enumerate(ssh_list)
)
git: tuple[GitEntry, ...] = ()
git_raw = d.get("git")
if git_raw is not None:
@@ -255,7 +214,6 @@ class Bottle:
for i, entry in enumerate(git_list)
)
_validate_unique_git_names(name, git)
_validate_no_shadow_route(name, ssh, git)
egress_raw = d.get("egress")
egress = (
@@ -264,7 +222,7 @@ class Bottle:
else BottleEgress()
)
return cls(env=env, ssh=ssh, git=git, egress=egress)
return cls(env=env, git=git, egress=egress)
@dataclass(frozen=True)
@@ -434,19 +392,6 @@ def _opt_str(value: object, label: str) -> str:
return value
def _opt_port(value: object, label: str) -> str:
"""Port accepts string or int (JSON-friendly) and is normalized to str."""
if value is None:
return ""
if isinstance(value, bool):
die(f"{label} must be a string or number (was boolean)")
if isinstance(value, int):
return str(value)
if isinstance(value, str):
return value
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
@@ -507,27 +452,3 @@ def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> N
seen[g.Name] = None
def _validate_no_shadow_route(
bottle_name: str,
ssh: tuple[SshEntry, ...],
git: tuple[GitEntry, ...],
) -> None:
"""Reject if any git entry's (host, port) matches an ssh entry's
(Hostname, Port). The same upstream reachable two ways — once through
the L4 ssh-gate, once through the gitleaks-bearing git-gate — defeats
the git-gate."""
ssh_targets: dict[tuple[str, str], str] = {}
for e in ssh:
if not e.Hostname:
continue
port = e.Port or "22"
ssh_targets[(e.Hostname, port)] = e.Host
for g in git:
ssh_host = ssh_targets.get((g.UpstreamHost, g.UpstreamPort))
if ssh_host is not None:
die(
f"bottle '{bottle_name}' has ssh entry '{ssh_host}' "
f"({g.UpstreamHost}:{g.UpstreamPort}) and git entry '{g.Name}' "
f"pointing at the same upstream. The same remote reachable two "
f"ways defeats the git-gate; remove one."
)