test: update test suite for git-gate manifest redesign (PRD 0047)
test / unit (pull_request) Successful in 37s
test / integration (pull_request) Successful in 55s

- fixtures.py: fixture_with_git_dict uses git-gate.repos + url/identity/host_key
- test_manifest_git: rewrite to use git-gate.repos; replace duplicate-name
  test (names = dict keys, always unique) with two-repos-different-hosts test
- test_manifest_git_user: _manifest → git-gate.user; update error message assertions
- test_manifest_agent_git_user: git → git-gate throughout; repos rejection test
- test_manifest_extends: git.remotes/git.user → git-gate.repos/git-gate.user
- test_provision_git: IP test updated — no host alias, single insteadOf
- test_compose: git.remotes → git-gate.repos + new field names
- test_docker_provision_git_user: git.user → git-gate.user
- test_git_gate: inline manifest dict updated to git-gate.repos
- test_smolmachines_provision: git_json → git_gate_json; remove _remote_host
This commit is contained in:
2026-06-03 03:55:07 +00:00
parent b7df5b5865
commit 58d76a50a6
10 changed files with 234 additions and 263 deletions
+11 -13
View File
@@ -38,23 +38,21 @@ def fixture_with_egress_dict() -> dict[str, Any]:
def fixture_with_git_dict() -> dict[str, Any]: def fixture_with_git_dict() -> dict[str, Any]:
"""Bottle declares a git-gate upstream. JSON shape.""" """Bottle declares git-gate upstreams. JSON shape."""
return { return {
"bottles": { "bottles": {
"dev": { "dev": {
"git": { "git-gate": {
"remotes": { "repos": {
"gitea.dideric.is": { "bot-bottle": {
"Name": "bot-bottle", "url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
"Upstream": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git", "identity": "/dev/null",
"IdentityFile": "/dev/null", "host_key": "ssh-ed25519 AAAA...",
"KnownHostKey": "ssh-ed25519 AAAA...",
}, },
"github.com": { "foo": {
"Name": "foo", "url": "ssh://git@github.com/didericis/foo.git",
"Upstream": "ssh://git@github.com/didericis/foo.git", "identity": "/dev/null",
"IdentityFile": "/dev/null", "host_key": "ssh-ed25519 BBBB...",
"KnownHostKey": "ssh-ed25519 BBBB...",
}, },
}, },
} }
+4 -5
View File
@@ -49,11 +49,10 @@ def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest
if supervise: if supervise:
bottle["supervise"] = True bottle["supervise"] = True
if with_git: if with_git:
bottle["git"] = {"remotes": { bottle["git-gate"] = {"repos": {
"example.com": { "upstream": {
"Name": "upstream", "url": "ssh://git@example.com:22/x/y.git",
"Upstream": "ssh://git@example.com:22/x/y.git", "identity": "/etc/hostname", # any existing file
"IdentityFile": "/etc/hostname", # any existing file
}, },
}} }}
if with_egress: if with_egress:
+1 -1
View File
@@ -30,7 +30,7 @@ def _plan(*, git_user: dict | None = None,
stage_dir: Path | None = None) -> DockerBottlePlan: stage_dir: Path | None = None) -> DockerBottlePlan:
bottle_json: dict = {} bottle_json: dict = {}
if git_user is not None: if git_user is not None:
bottle_json["git"] = {"user": git_user} bottle_json["git-gate"] = {"user": git_user}
manifest = Manifest.from_json_obj({ manifest = Manifest.from_json_obj({
"bottles": {"dev": bottle_json}, "bottles": {"dev": bottle_json},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
+4 -5
View File
@@ -220,11 +220,10 @@ class TestPrepare(unittest.TestCase):
def test_prepare_skips_known_hosts_file_when_key_missing(self): def test_prepare_skips_known_hosts_file_when_key_missing(self):
manifest = Manifest.from_json_obj({ manifest = Manifest.from_json_obj({
"bottles": {"dev": {"git": {"remotes": { "bottles": {"dev": {"git-gate": {"repos": {
"github.com": { "foo": {
"Name": "foo", "url": "ssh://git@github.com/didericis/foo.git",
"Upstream": "ssh://git@github.com/didericis/foo.git", "identity": "/dev/null",
"IdentityFile": "/dev/null",
}, },
}}}}, }}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
+27 -33
View File
@@ -1,14 +1,14 @@
"""Unit: agent-level git.user overlay + provenance (PRD 0027, issue #94). """Unit: agent-level git-gate.user overlay + provenance (PRD 0027, PRD 0047).
An agent file may declare `git.user` (name/email). At An agent file may declare `git-gate.user` (name/email). At
`Manifest.bottle_for()` it overlays the referenced bottle's `Manifest.bottle_for()` it overlays the referenced bottle's
`git.user` per-field, agent-wins-on-non-empty. `git.remotes` is `git-gate.user` per-field, agent-wins-on-non-empty. `git-gate.repos` is
rejected on agents. `Manifest.git_identity_summary()` reports the rejected on agents. `Manifest.git_identity_summary()` reports the
effective identity with per-field `(agent)`/`(bottle)` provenance. effective identity with per-field `(agent)`/`(bottle)` provenance.
The `from_json_obj` path drives `Agent.from_dict` + `bottle_for`; The `from_json_obj` path drives `Agent.from_dict` + `bottle_for`;
a temp-dir case locks the md loader (the `_AGENT_KEYS` allow + the a temp-dir case locks the md loader (the `_AGENT_KEYS` allow + the
`git` threading into `agent_dict`).""" `git-gate` threading into `agent_dict`)."""
from __future__ import annotations from __future__ import annotations
@@ -34,10 +34,10 @@ def _error_message(callable_, *args, **kwargs) -> str:
def _manifest(*, bottle_user=None, agent_git=None) -> Manifest: def _manifest(*, bottle_user=None, agent_git=None) -> Manifest:
bottle: dict = {} bottle: dict = {}
if bottle_user is not None: if bottle_user is not None:
bottle = {"git": {"user": bottle_user}} bottle = {"git-gate": {"user": bottle_user}}
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} agent: dict = {"skills": [], "prompt": "", "bottle": "dev"}
if agent_git is not None: if agent_git is not None:
agent["git"] = agent_git agent["git-gate"] = agent_git
return Manifest.from_json_obj({ return Manifest.from_json_obj({
"bottles": {"dev": bottle}, "bottles": {"dev": bottle},
"agents": {"impl": agent}, "agents": {"impl": agent},
@@ -71,7 +71,6 @@ class TestAgentGitUserOverlay(unittest.TestCase):
def test_agent_identity_with_bottle_declaring_none(self): def test_agent_identity_with_bottle_declaring_none(self):
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}}) m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
# The underlying bottle declares no identity; the merged one does.
self.assertTrue(m.bottles["dev"].git_user.is_empty()) self.assertTrue(m.bottles["dev"].git_user.is_empty())
self.assertFalse(m.bottle_for("impl").git_user.is_empty()) self.assertFalse(m.bottle_for("impl").git_user.is_empty())
@@ -82,14 +81,10 @@ class TestAgentGitUserOverlay(unittest.TestCase):
self.assertEqual("b@c", u.email) self.assertEqual("b@c", u.email)
def test_bottle_for_returns_same_instance_when_no_overlay(self): def test_bottle_for_returns_same_instance_when_no_overlay(self):
# No agent git.user → no replace(); the cached Bottle is
# returned as-is (identity check guards against churn).
m = _manifest(bottle_user={"name": "B"}) m = _manifest(bottle_user={"name": "B"})
self.assertIs(m.bottles["dev"], m.bottle_for("impl")) self.assertIs(m.bottles["dev"], m.bottle_for("impl"))
def test_bottle_for_returns_same_instance_when_overlay_is_noop(self): def test_bottle_for_returns_same_instance_when_overlay_is_noop(self):
# Agent restates exactly what the bottle already has → merged
# == bottle.git_user → same instance, no replace().
m = _manifest( m = _manifest(
bottle_user={"name": "B", "email": "b@c"}, bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"name": "B", "email": "b@c"}}, agent_git={"user": {"name": "B", "email": "b@c"}},
@@ -101,11 +96,11 @@ class TestAgentGitUserOverlay(unittest.TestCase):
"bottles": {"dev": { "bottles": {"dev": {
"env": {"FOO": "bar"}, "env": {"FOO": "bar"},
"supervise": True, "supervise": True,
"git": {"user": {"name": "B"}}, "git-gate": {"user": {"name": "B"}},
}}, }},
"agents": {"impl": { "agents": {"impl": {
"bottle": "dev", "skills": [], "prompt": "", "bottle": "dev", "skills": [], "prompt": "",
"git": {"user": {"name": "a"}}, "git-gate": {"user": {"name": "a"}},
}}, }},
}) })
b = m.bottle_for("impl") b = m.bottle_for("impl")
@@ -115,11 +110,11 @@ class TestAgentGitUserOverlay(unittest.TestCase):
class TestAgentGitUserRejections(unittest.TestCase): class TestAgentGitUserRejections(unittest.TestCase):
def test_agent_remotes_dies_bottle_only(self): def test_agent_repos_dies_bottle_only(self):
msg = _error_message(_manifest, agent_git={ msg = _error_message(_manifest, agent_git={
"remotes": {"h": {"Name": "r", "Upstream": "ssh://x/y.git"}}, "repos": {"r": {"url": "ssh://git@x/y.git", "identity": "/dev/null"}},
}) })
self.assertIn("git.remotes", msg) self.assertIn("git-gate.repos", msg)
self.assertIn("bottle-only", msg) self.assertIn("bottle-only", msg)
def test_agent_unknown_git_subkey_dies(self): def test_agent_unknown_git_subkey_dies(self):
@@ -127,7 +122,6 @@ class TestAgentGitUserRejections(unittest.TestCase):
self.assertIn("not allowed at the agent level", msg) self.assertIn("not allowed at the agent level", msg)
def test_agent_git_user_both_empty_dies(self): def test_agent_git_user_both_empty_dies(self):
# Reuses GitUser.from_dict validation.
msg = _error_message(_manifest, agent_git={"user": {"name": "", "email": ""}}) msg = _error_message(_manifest, agent_git={"user": {"name": "", "email": ""}})
self.assertIn("neither name nor email", msg) self.assertIn("neither name nor email", msg)
@@ -164,7 +158,7 @@ class TestGitIdentitySummary(unittest.TestCase):
_BOTTLE_DEV = """ _BOTTLE_DEV = """
--- ---
git: git-gate:
user: user:
name: bottle-name name: bottle-name
email: bottle@example.com email: bottle@example.com
@@ -176,7 +170,7 @@ _BOTTLE_DEV = """
_AGENT_WITH_GIT = """ _AGENT_WITH_GIT = """
--- ---
bottle: dev bottle: dev
git: git-gate:
user: user:
name: agent-name name: agent-name
--- ---
@@ -184,14 +178,14 @@ _AGENT_WITH_GIT = """
impl agent. impl agent.
""" """
_AGENT_WITH_REMOTES = """ _AGENT_WITH_REPOS = """
--- ---
bottle: dev bottle: dev
git: git-gate:
remotes: repos:
h: r:
Name: r url: ssh://git@x/y.git
Upstream: ssh://x/y.git identity: /dev/null
--- ---
bad agent. bad agent.
@@ -199,9 +193,9 @@ _AGENT_WITH_REMOTES = """
class TestAgentGitUserMdLoader(unittest.TestCase): class TestAgentGitUserMdLoader(unittest.TestCase):
"""Locks the md path: `git` is an accepted agent key and threads """Locks the md path: `git-gate` is an accepted agent key and threads
into the parsed Agent (not rejected as an unknown frontmatter into the parsed Agent (not rejected as an unknown frontmatter key),
key), and agent `git.remotes` dies through the same loader.""" and agent `git-gate.repos` dies through the same loader."""
def setUp(self) -> None: def setUp(self) -> None:
self.home = Path(tempfile.mkdtemp(prefix="cb-home-")) self.home = Path(tempfile.mkdtemp(prefix="cb-home-"))
@@ -225,18 +219,18 @@ class TestAgentGitUserMdLoader(unittest.TestCase):
self._write("agents/impl.md", _AGENT_WITH_GIT) self._write("agents/impl.md", _AGENT_WITH_GIT)
m = Manifest.resolve(str(self.home)) m = Manifest.resolve(str(self.home))
u = m.bottle_for("impl").git_user u = m.bottle_for("impl").git_user
self.assertEqual("agent-name", u.name) # agent wins self.assertEqual("agent-name", u.name)
self.assertEqual("bottle@example.com", u.email) # bottle falls through self.assertEqual("bottle@example.com", u.email)
self.assertEqual( self.assertEqual(
"name=agent-name (agent), email=bottle@example.com (bottle)", "name=agent-name (agent), email=bottle@example.com (bottle)",
m.git_identity_summary("impl"), m.git_identity_summary("impl"),
) )
def test_md_agent_remotes_dies(self): def test_md_agent_repos_dies(self):
self._write("bottles/dev.md", _BOTTLE_DEV) self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_REMOTES) self._write("agents/impl.md", _AGENT_WITH_REPOS)
msg = _error_message(Manifest.resolve, str(self.home)) msg = _error_message(Manifest.resolve, str(self.home))
self.assertIn("git.remotes", msg) self.assertIn("git-gate.repos", msg)
self.assertIn("bottle-only", msg) self.assertIn("bottle-only", msg)
+30 -45
View File
@@ -113,42 +113,30 @@ class TestExtendsEnvMerge(unittest.TestCase):
class TestExtendsGitMerge(unittest.TestCase): class TestExtendsGitMerge(unittest.TestCase):
"""git.user overlays by field; git.remotes merges by upstream """git-gate.user overlays by field; git-gate.repos merges by upstream
host, with child entries replacing duplicate hosts.""" host, with child entries replacing duplicate hosts."""
_GIT_ENTRY_A = { _GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "identity": "/dev/null"}
"Name": "a", _GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "identity": "/dev/null"}
"Upstream": "ssh://git@host-a/a.git",
"IdentityFile": "/dev/null",
}
_GIT_ENTRY_B = {
"Name": "b",
"Upstream": "ssh://git@host-b/b.git",
"IdentityFile": "/dev/null",
}
def test_child_git_remotes_merge_with_parent(self): def test_child_git_repos_merge_with_parent(self):
m = _build( m = _build(
base={"git": {"remotes": {"host-a": self._GIT_ENTRY_A}}}, base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
child={ child={
"extends": "base", "extends": "base",
"git": {"remotes": {"host-b": self._GIT_ENTRY_B}}, "git-gate": {"repos": {"b": self._GIT_ENTRY_B}},
}, },
) )
names = [e.Name for e in m.bottles["child"].git] names = [e.Name for e in m.bottles["child"].git]
self.assertEqual(["a", "b"], names) self.assertEqual(["a", "b"], names)
def test_child_git_remote_replaces_same_host(self): def test_child_git_repo_replaces_same_host(self):
replacement = { replacement = {"url": "ssh://git@host-a/replacement.git", "identity": "/dev/null"}
"Name": "a2",
"Upstream": "ssh://git@host-a/replacement.git",
"IdentityFile": "/dev/null",
}
m = _build( m = _build(
base={"git": {"remotes": {"host-a": self._GIT_ENTRY_A}}}, base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
child={ child={
"extends": "base", "extends": "base",
"git": {"remotes": {"host-a": replacement}}, "git-gate": {"repos": {"a2": replacement}},
}, },
) )
entries = m.bottles["child"].git entries = m.bottles["child"].git
@@ -156,30 +144,30 @@ class TestExtendsGitMerge(unittest.TestCase):
self.assertEqual("a2", entries[0].Name) self.assertEqual("a2", entries[0].Name)
self.assertEqual("replacement.git", entries[0].UpstreamPath) self.assertEqual("replacement.git", entries[0].UpstreamPath)
def test_child_omits_git_inherits_full_list(self): def test_child_omits_git_gate_inherits_full_list(self):
m = _build( m = _build(
base={"git": {"remotes": { base={"git-gate": {"repos": {
"host-a": self._GIT_ENTRY_A, "a": self._GIT_ENTRY_A,
"host-b": self._GIT_ENTRY_B, "b": self._GIT_ENTRY_B,
}}}, }}},
child={"extends": "base"}, child={"extends": "base"},
) )
names = [e.Name for e in m.bottles["child"].git] names = [e.Name for e in m.bottles["child"].git]
self.assertEqual(["a", "b"], names) self.assertEqual(["a", "b"], names)
def test_child_explicit_empty_git_clears_parent(self): def test_child_explicit_empty_repos_clears_parent(self):
# `git.remotes: {}` is the documented way to say "drop # `git-gate.repos: {}` is the documented way to say "drop
# the parent's remotes" rather than "inherit them". # the parent's repos" rather than "inherit them".
m = _build( m = _build(
base={"git": {"remotes": {"host-a": self._GIT_ENTRY_A}}}, base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
child={"extends": "base", "git": {"remotes": {}}}, child={"extends": "base", "git-gate": {"repos": {}}},
) )
self.assertEqual((), m.bottles["child"].git) self.assertEqual((), m.bottles["child"].git)
def test_child_git_user_inherits_parent_remotes(self): def test_child_git_user_inherits_parent_repos(self):
m = _build( m = _build(
base={"git": {"remotes": {"host-a": self._GIT_ENTRY_A}}}, base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
child={"extends": "base", "git": {"user": {"name": "Child"}}}, child={"extends": "base", "git-gate": {"user": {"name": "Child"}}},
) )
self.assertEqual(["a"], [e.Name for e in m.bottles["child"].git]) self.assertEqual(["a"], [e.Name for e in m.bottles["child"].git])
self.assertEqual("Child", m.bottles["child"].git_user.name) self.assertEqual("Child", m.bottles["child"].git_user.name)
@@ -209,12 +197,12 @@ class TestExtendsListsFullReplace(unittest.TestCase):
class TestExtendsGitUserOverlay(unittest.TestCase): class TestExtendsGitUserOverlay(unittest.TestCase):
"""git.user: per-field overlay. Each non-empty field on child """git-gate.user: per-field overlay. Each non-empty field on child
wins; empties fall through to parent.""" wins; empties fall through to parent."""
def test_parent_full_child_omits(self): def test_parent_full_child_omits(self):
m = _build( m = _build(
base={"git": {"user": {"name": "Parent", "email": "p@x"}}}, base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
child={"extends": "base"}, child={"extends": "base"},
) )
u = m.bottles["child"].git_user u = m.bottles["child"].git_user
@@ -223,10 +211,10 @@ class TestExtendsGitUserOverlay(unittest.TestCase):
def test_child_overrides_both(self): def test_child_overrides_both(self):
m = _build( m = _build(
base={"git": {"user": {"name": "Parent", "email": "p@x"}}}, base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
child={ child={
"extends": "base", "extends": "base",
"git": {"user": {"name": "Child", "email": "c@x"}}, "git-gate": {"user": {"name": "Child", "email": "c@x"}},
}, },
) )
u = m.bottles["child"].git_user u = m.bottles["child"].git_user
@@ -234,11 +222,9 @@ class TestExtendsGitUserOverlay(unittest.TestCase):
self.assertEqual("c@x", u.email) self.assertEqual("c@x", u.email)
def test_child_adds_email_inherits_name(self): def test_child_adds_email_inherits_name(self):
# Parent sets only name; child sets only email. Both end
# up populated on the child.
m = _build( m = _build(
base={"git": {"user": {"name": "Parent"}}}, base={"git-gate": {"user": {"name": "Parent"}}},
child={"extends": "base", "git": {"user": {"email": "c@x"}}}, child={"extends": "base", "git-gate": {"user": {"email": "c@x"}}},
) )
u = m.bottles["child"].git_user u = m.bottles["child"].git_user
self.assertEqual("Parent", u.name) self.assertEqual("Parent", u.name)
@@ -246,11 +232,10 @@ class TestExtendsGitUserOverlay(unittest.TestCase):
def test_child_overrides_only_email(self): def test_child_overrides_only_email(self):
m = _build( m = _build(
base={"git": {"user": {"name": "Parent", "email": "p@x"}}}, base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
child={"extends": "base", "git": {"user": {"email": "c@x"}}}, child={"extends": "base", "git-gate": {"user": {"email": "c@x"}}},
) )
u = m.bottles["child"].git_user u = m.bottles["child"].git_user
# Child overrides email; name inherited from parent.
self.assertEqual("Parent", u.name) self.assertEqual("Parent", u.name)
self.assertEqual("c@x", u.email) self.assertEqual("c@x", u.email)
+136 -131
View File
@@ -1,39 +1,25 @@
"""Unit: Bottle.git manifest parsing + validation (PRD 0008).""" """Unit: git-gate.repos manifest parsing + validation (PRD 0047)."""
import unittest import unittest
from bot_bottle.manifest import ManifestError, Manifest from bot_bottle.manifest import ManifestError, Manifest
def _manifest(git_entries): def _manifest(repos: dict) -> dict:
return { return {
"bottles": {"dev": {"git": {"remotes": { "bottles": {"dev": {"git-gate": {"repos": repos}}},
_host_for(entry): entry for entry in git_entries
}}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
} }
def _host_for(entry):
upstream = entry.get("Upstream", "")
if "@a.example" in upstream:
return "a.example"
if "@b.example" in upstream:
return "b.example"
if "@github.com" in upstream:
return "github.com"
if "@gitea.dideric.is" in upstream:
return "gitea.dideric.is"
return "example.com"
class TestGitEntryParsing(unittest.TestCase): class TestGitEntryParsing(unittest.TestCase):
def test_parses_minimal_entry(self): def test_parses_minimal_entry(self):
m = Manifest.from_json_obj(_manifest([{ m = Manifest.from_json_obj(_manifest({
"Name": "bot-bottle", "bot-bottle": {
"Upstream": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git", "url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
"IdentityFile": "/dev/null", "identity": "/dev/null",
}])) },
}))
entries = m.bottles["dev"].git entries = m.bottles["dev"].git
self.assertEqual(1, len(entries)) self.assertEqual(1, len(entries))
e = entries[0] e = entries[0]
@@ -44,138 +30,145 @@ class TestGitEntryParsing(unittest.TestCase):
self.assertEqual("didericis/bot-bottle.git", e.UpstreamPath) self.assertEqual("didericis/bot-bottle.git", e.UpstreamPath)
def test_default_port_is_22(self): def test_default_port_is_22(self):
m = Manifest.from_json_obj(_manifest([{ m = Manifest.from_json_obj(_manifest({
"Name": "foo", "foo": {
"Upstream": "ssh://git@github.com/didericis/foo.git", "url": "ssh://git@github.com/didericis/foo.git",
"IdentityFile": "/dev/null", "identity": "/dev/null",
}])) },
}))
e = m.bottles["dev"].git[0] e = m.bottles["dev"].git[0]
self.assertEqual("22", e.UpstreamPort) self.assertEqual("22", e.UpstreamPort)
self.assertEqual("github.com", e.UpstreamHost) self.assertEqual("github.com", e.UpstreamHost)
def test_known_host_key_optional(self): def test_host_key_optional(self):
m = Manifest.from_json_obj(_manifest([{ m = Manifest.from_json_obj(_manifest({
"Name": "foo", "foo": {
"Upstream": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"IdentityFile": "/dev/null", "identity": "/dev/null",
}])) },
}))
self.assertEqual("", m.bottles["dev"].git[0].KnownHostKey) self.assertEqual("", m.bottles["dev"].git[0].KnownHostKey)
def test_missing_name_dies(self): def test_host_key_stored(self):
with self.assertRaises(ManifestError): m = Manifest.from_json_obj(_manifest({
Manifest.from_json_obj(_manifest([{ "foo": {
"Upstream": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"IdentityFile": "/dev/null", "identity": "/dev/null",
}])) "host_key": "ssh-ed25519 AAAA",
},
}))
self.assertEqual("ssh-ed25519 AAAA", m.bottles["dev"].git[0].KnownHostKey)
def test_missing_upstream_dies(self): def test_repo_name_becomes_Name(self):
with self.assertRaises(ManifestError): m = Manifest.from_json_obj(_manifest({
Manifest.from_json_obj(_manifest([{ "my-repo": {
"Name": "foo", "url": "ssh://git@github.com/foo.git",
"IdentityFile": "/dev/null", "identity": "/dev/null",
}])) },
}))
self.assertEqual("my-repo", m.bottles["dev"].git[0].Name)
def test_missing_identity_file_dies(self): def test_missing_url_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest([{ Manifest.from_json_obj(_manifest({
"Name": "foo", "foo": {"identity": "/dev/null"},
"Upstream": "ssh://git@github.com/foo.git", }))
}]))
def test_non_ssh_upstream_dies(self): def test_missing_identity_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest([{ Manifest.from_json_obj(_manifest({
"Name": "foo", "foo": {"url": "ssh://git@github.com/foo.git"},
"Upstream": "https://github.com/didericis/foo.git", }))
"IdentityFile": "/dev/null",
}]))
def test_scp_style_upstream_dies(self): def test_unknown_key_in_entry_dies(self):
# SCP-style "git@host:path" is intentionally not supported in
# v1 — ssh:// only.
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest([{ Manifest.from_json_obj(_manifest({
"Name": "foo", "foo": {
"Upstream": "git@github.com:didericis/foo.git", "url": "ssh://git@github.com/foo.git",
"IdentityFile": "/dev/null", "identity": "/dev/null",
}])) "IdentityFile": "/dev/null", # old PascalCase key
},
}))
def test_upstream_without_user_dies(self): def test_non_ssh_url_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest([{ Manifest.from_json_obj(_manifest({
"Name": "foo", "foo": {
"Upstream": "ssh://github.com/foo.git", "url": "https://github.com/didericis/foo.git",
"IdentityFile": "/dev/null", "identity": "/dev/null",
}])) },
}))
def test_upstream_without_path_dies(self): def test_scp_style_url_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest([{ Manifest.from_json_obj(_manifest({
"Name": "foo", "foo": {
"Upstream": "ssh://git@github.com", "url": "git@github.com:didericis/foo.git",
"IdentityFile": "/dev/null", "identity": "/dev/null",
}])) },
}))
def test_url_without_user_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
"foo": {
"url": "ssh://github.com/foo.git",
"identity": "/dev/null",
},
}))
def test_url_without_path_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com",
"identity": "/dev/null",
},
}))
def test_non_numeric_port_dies(self): def test_non_numeric_port_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest([{ Manifest.from_json_obj(_manifest({
"Name": "foo", "foo": {
"Upstream": "ssh://git@github.com:notaport/foo.git", "url": "ssh://git@github.com:notaport/foo.git",
"IdentityFile": "/dev/null", "identity": "/dev/null",
}])) },
}))
def test_ip_literal_upstream(self):
m = Manifest.from_json_obj(_manifest({
"bot-bottle": {
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
"identity": "/dev/null",
},
}))
e = m.bottles["dev"].git[0]
self.assertEqual("100.78.141.42", e.UpstreamHost)
self.assertEqual("30009", e.UpstreamPort)
self.assertEqual("bot-bottle", e.Name)
class TestGitEntryCrossValidation(unittest.TestCase): class TestGitEntryCrossValidation(unittest.TestCase):
def test_duplicate_name_dies(self): def test_two_repos_different_hosts_both_parsed(self):
with self.assertRaises(ManifestError): # Repo names come from dict keys; two distinct keys always produce
Manifest.from_json_obj({ # two distinct entries (uniqueness is guaranteed at the YAML/dict level).
"bottles": {"dev": {"git": {"remotes": {
"a.example": {
"Name": "foo",
"Upstream": "ssh://git@a.example/x.git",
"IdentityFile": "/dev/null",
},
"b.example": {
"Name": "foo",
"Upstream": "ssh://git@b.example/y.git",
"IdentityFile": "/dev/null",
},
}}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
def test_remote_key_must_match_upstream_host(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj({
"bottles": {"dev": {"git": {"remotes": {
"wrong.example": {
"Name": "foo",
"Upstream": "ssh://git@github.com/foo.git",
"IdentityFile": "/dev/null",
},
}}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
def test_remote_key_can_name_logical_host_for_ip_upstream(self):
m = Manifest.from_json_obj({ m = Manifest.from_json_obj({
"bottles": {"dev": {"git": {"remotes": { "bottles": {"dev": {"git-gate": {"repos": {
"gitea.dideric.is": { "foo": {
"Name": "bot-bottle", "url": "ssh://git@a.example/x.git",
"Upstream": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git", "identity": "/dev/null",
"IdentityFile": "/dev/null", },
"bar": {
"url": "ssh://git@b.example/y.git",
"identity": "/dev/null",
}, },
}}}}, }}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
e = m.bottles["dev"].git[0] names = {e.Name for e in m.bottles["dev"].git}
self.assertEqual("gitea.dideric.is", e.RemoteKey) self.assertEqual({"foo", "bar"}, names)
self.assertEqual("100.78.141.42", e.UpstreamHost)
self.assertEqual("30009", e.UpstreamPort)
def test_legacy_ssh_field_dies_with_hint(self): def test_legacy_ssh_field_dies_with_hint(self):
# PRD 0009: bottle.ssh is removed; manifests carrying it must
# fail loudly with a hint pointing at bottle.git.
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
Manifest.from_json_obj({ Manifest.from_json_obj({
"bottles": { "bottles": {
@@ -192,25 +185,37 @@ class TestGitEntryCrossValidation(unittest.TestCase):
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
def test_legacy_git_key_dies_with_hint(self):
msg = ""
try:
Manifest.from_json_obj({
"bottles": {"dev": {"git": {"remotes": {}}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
except ManifestError as e:
msg = str(e)
self.assertIn("git-gate", msg)
self.assertIn("PRD 0047", msg)
class TestEmptyGitField(unittest.TestCase):
def test_no_git_field_yields_empty_tuple(self): class TestEmptyGitGateField(unittest.TestCase):
def test_no_git_gate_field_yields_empty_tuple(self):
m = Manifest.from_json_obj({ m = Manifest.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
self.assertEqual((), m.bottles["dev"].git) self.assertEqual((), m.bottles["dev"].git)
def test_git_object_type_required(self): def test_git_gate_object_type_required(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
Manifest.from_json_obj({ Manifest.from_json_obj({
"bottles": {"dev": {"git": "not-a-list"}}, "bottles": {"dev": {"git-gate": "not-a-dict"}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
def test_empty_remotes_yields_empty_tuple(self): def test_empty_repos_yields_empty_tuple(self):
m = Manifest.from_json_obj({ m = Manifest.from_json_obj({
"bottles": {"dev": {"git": {"remotes": {}}}}, "bottles": {"dev": {"git-gate": {"repos": {}}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
self.assertEqual((), m.bottles["dev"].git) self.assertEqual((), m.bottles["dev"].git)
+5 -5
View File
@@ -1,4 +1,4 @@
"""Unit: Bottle git.user manifest parsing + validation (issue #86).""" """Unit: Bottle git-gate.user manifest parsing + validation (issue #86, PRD 0047)."""
import unittest import unittest
@@ -16,7 +16,7 @@ def _error_message(callable_, *args, **kwargs) -> str:
def _manifest(git_user): def _manifest(git_user):
return { return {
"bottles": {"dev": {"git": {"user": git_user}}}, "bottles": {"dev": {"git-gate": {"user": git_user}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
} }
@@ -75,13 +75,13 @@ class TestGitUserParsing(unittest.TestCase):
msg = _error_message( msg = _error_message(
Manifest.from_json_obj, _manifest({"name": 42}), Manifest.from_json_obj, _manifest({"name": 42}),
) )
self.assertIn("git.user.name must be a string", msg) self.assertIn("git-gate.user.name must be a string", msg)
def test_non_string_email_dies(self): def test_non_string_email_dies(self):
msg = _error_message( msg = _error_message(
Manifest.from_json_obj, _manifest({"email": ["x@y.z"]}), Manifest.from_json_obj, _manifest({"email": ["x@y.z"]}),
) )
self.assertIn("git.user.email must be a string", msg) self.assertIn("git-gate.user.email must be a string", msg)
def test_legacy_top_level_git_user_dies(self): def test_legacy_top_level_git_user_dies(self):
msg = _error_message( msg = _error_message(
@@ -92,7 +92,7 @@ class TestGitUserParsing(unittest.TestCase):
}, },
) )
self.assertIn("git_user", msg) self.assertIn("git_user", msg)
self.assertIn("git.user", msg) self.assertIn("git-gate.user", msg)
class TestGitUserDirect(unittest.TestCase): class TestGitUserDirect(unittest.TestCase):
+8 -11
View File
@@ -69,13 +69,14 @@ class TestGitGateGitconfigRender(unittest.TestCase):
'[url "http://127.0.0.16:57001/bot-bottle.git"]', out, '[url "http://127.0.0.16:57001/bot-bottle.git"]', out,
) )
def test_ip_upstream_also_rewrites_logical_remote_key(self): def test_ip_upstream_emits_single_insteadof(self):
# In the new format the dict key is the repo name, not a host
# alias, so there is only one insteadOf rule — for the IP URL.
m = Manifest.from_json_obj({ m = Manifest.from_json_obj({
"bottles": {"dev": {"git": {"remotes": { "bottles": {"dev": {"git-gate": {"repos": {
"gitea.dideric.is": { "bot-bottle": {
"Name": "bot-bottle", "url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
"Upstream": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git", "identity": "/dev/null",
"IdentityFile": "/dev/null",
}, },
}}}}, }}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
@@ -86,11 +87,7 @@ class TestGitGateGitconfigRender(unittest.TestCase):
"ssh://git@100.78.141.42:30009/didericis/bot-bottle.git", "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
out, out,
) )
self.assertIn( self.assertNotIn("gitea.dideric.is", out)
"\tinsteadOf = "
"ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
out,
)
if __name__ == "__main__": if __name__ == "__main__":
+8 -14
View File
@@ -42,11 +42,6 @@ from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan from bot_bottle.workspace import workspace_plan
def _remote_host(g: GitEntry) -> str:
if g.UpstreamHost:
return g.UpstreamHost
return g.Upstream.split("@", 1)[1].split("/", 1)[0].split(":", 1)[0]
def _plan( def _plan(
*, *,
@@ -69,20 +64,19 @@ def _plan(
guest_env: dict[str, str] | None = None, guest_env: dict[str, str] | None = None,
) -> SmolmachinesBottlePlan: ) -> SmolmachinesBottlePlan:
bottle_json: dict = {} bottle_json: dict = {}
git_json: dict = {} git_gate_json: dict = {}
if git: if git:
git_json["remotes"] = { git_gate_json["repos"] = {
_remote_host(g): { g.Name: {
"Name": g.Name, "url": g.Upstream,
"Upstream": g.Upstream, "identity": g.IdentityFile,
"IdentityFile": g.IdentityFile,
} }
for g in git for g in git
} }
if git_user is not None: if git_user is not None:
git_json["user"] = git_user git_gate_json["user"] = git_user
if git_json: if git_gate_json:
bottle_json["git"] = git_json bottle_json["git-gate"] = git_gate_json
if supervise: if supervise:
bottle_json["supervise"] = True bottle_json["supervise"] = True
manifest = Manifest.from_json_obj({ manifest = Manifest.from_json_obj({