test: update test suite for git-gate manifest redesign (PRD 0047)
- 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:
+11
-13
@@ -38,23 +38,21 @@ def fixture_with_egress_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 {
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"git": {
|
||||
"remotes": {
|
||||
"gitea.dideric.is": {
|
||||
"Name": "bot-bottle",
|
||||
"Upstream": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
||||
"IdentityFile": "/dev/null",
|
||||
"KnownHostKey": "ssh-ed25519 AAAA...",
|
||||
"git-gate": {
|
||||
"repos": {
|
||||
"bot-bottle": {
|
||||
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
||||
"identity": "/dev/null",
|
||||
"host_key": "ssh-ed25519 AAAA...",
|
||||
},
|
||||
"github.com": {
|
||||
"Name": "foo",
|
||||
"Upstream": "ssh://git@github.com/didericis/foo.git",
|
||||
"IdentityFile": "/dev/null",
|
||||
"KnownHostKey": "ssh-ed25519 BBBB...",
|
||||
"foo": {
|
||||
"url": "ssh://git@github.com/didericis/foo.git",
|
||||
"identity": "/dev/null",
|
||||
"host_key": "ssh-ed25519 BBBB...",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -49,11 +49,10 @@ def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest
|
||||
if supervise:
|
||||
bottle["supervise"] = True
|
||||
if with_git:
|
||||
bottle["git"] = {"remotes": {
|
||||
"example.com": {
|
||||
"Name": "upstream",
|
||||
"Upstream": "ssh://git@example.com:22/x/y.git",
|
||||
"IdentityFile": "/etc/hostname", # any existing file
|
||||
bottle["git-gate"] = {"repos": {
|
||||
"upstream": {
|
||||
"url": "ssh://git@example.com:22/x/y.git",
|
||||
"identity": "/etc/hostname", # any existing file
|
||||
},
|
||||
}}
|
||||
if with_egress:
|
||||
|
||||
@@ -30,7 +30,7 @@ def _plan(*, git_user: dict | None = None,
|
||||
stage_dir: Path | None = None) -> DockerBottlePlan:
|
||||
bottle_json: dict = {}
|
||||
if git_user is not None:
|
||||
bottle_json["git"] = {"user": git_user}
|
||||
bottle_json["git-gate"] = {"user": git_user}
|
||||
manifest = Manifest.from_json_obj({
|
||||
"bottles": {"dev": bottle_json},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
|
||||
@@ -220,11 +220,10 @@ class TestPrepare(unittest.TestCase):
|
||||
|
||||
def test_prepare_skips_known_hosts_file_when_key_missing(self):
|
||||
manifest = Manifest.from_json_obj({
|
||||
"bottles": {"dev": {"git": {"remotes": {
|
||||
"github.com": {
|
||||
"Name": "foo",
|
||||
"Upstream": "ssh://git@github.com/didericis/foo.git",
|
||||
"IdentityFile": "/dev/null",
|
||||
"bottles": {"dev": {"git-gate": {"repos": {
|
||||
"foo": {
|
||||
"url": "ssh://git@github.com/didericis/foo.git",
|
||||
"identity": "/dev/null",
|
||||
},
|
||||
}}}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
|
||||
@@ -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
|
||||
`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
|
||||
effective identity with per-field `(agent)`/`(bottle)` provenance.
|
||||
|
||||
The `from_json_obj` path drives `Agent.from_dict` + `bottle_for`;
|
||||
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
|
||||
|
||||
@@ -34,10 +34,10 @@ def _error_message(callable_, *args, **kwargs) -> str:
|
||||
def _manifest(*, bottle_user=None, agent_git=None) -> Manifest:
|
||||
bottle: dict = {}
|
||||
if bottle_user is not None:
|
||||
bottle = {"git": {"user": bottle_user}}
|
||||
bottle = {"git-gate": {"user": bottle_user}}
|
||||
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"}
|
||||
if agent_git is not None:
|
||||
agent["git"] = agent_git
|
||||
agent["git-gate"] = agent_git
|
||||
return Manifest.from_json_obj({
|
||||
"bottles": {"dev": bottle},
|
||||
"agents": {"impl": agent},
|
||||
@@ -71,7 +71,6 @@ class TestAgentGitUserOverlay(unittest.TestCase):
|
||||
|
||||
def test_agent_identity_with_bottle_declaring_none(self):
|
||||
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.assertFalse(m.bottle_for("impl").git_user.is_empty())
|
||||
|
||||
@@ -82,14 +81,10 @@ class TestAgentGitUserOverlay(unittest.TestCase):
|
||||
self.assertEqual("b@c", u.email)
|
||||
|
||||
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"})
|
||||
self.assertIs(m.bottles["dev"], m.bottle_for("impl"))
|
||||
|
||||
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(
|
||||
bottle_user={"name": "B", "email": "b@c"},
|
||||
agent_git={"user": {"name": "B", "email": "b@c"}},
|
||||
@@ -101,11 +96,11 @@ class TestAgentGitUserOverlay(unittest.TestCase):
|
||||
"bottles": {"dev": {
|
||||
"env": {"FOO": "bar"},
|
||||
"supervise": True,
|
||||
"git": {"user": {"name": "B"}},
|
||||
"git-gate": {"user": {"name": "B"}},
|
||||
}},
|
||||
"agents": {"impl": {
|
||||
"bottle": "dev", "skills": [], "prompt": "",
|
||||
"git": {"user": {"name": "a"}},
|
||||
"git-gate": {"user": {"name": "a"}},
|
||||
}},
|
||||
})
|
||||
b = m.bottle_for("impl")
|
||||
@@ -115,11 +110,11 @@ class TestAgentGitUserOverlay(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={
|
||||
"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)
|
||||
|
||||
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)
|
||||
|
||||
def test_agent_git_user_both_empty_dies(self):
|
||||
# Reuses GitUser.from_dict validation.
|
||||
msg = _error_message(_manifest, agent_git={"user": {"name": "", "email": ""}})
|
||||
self.assertIn("neither name nor email", msg)
|
||||
|
||||
@@ -164,7 +158,7 @@ class TestGitIdentitySummary(unittest.TestCase):
|
||||
|
||||
_BOTTLE_DEV = """
|
||||
---
|
||||
git:
|
||||
git-gate:
|
||||
user:
|
||||
name: bottle-name
|
||||
email: bottle@example.com
|
||||
@@ -176,7 +170,7 @@ _BOTTLE_DEV = """
|
||||
_AGENT_WITH_GIT = """
|
||||
---
|
||||
bottle: dev
|
||||
git:
|
||||
git-gate:
|
||||
user:
|
||||
name: agent-name
|
||||
---
|
||||
@@ -184,14 +178,14 @@ _AGENT_WITH_GIT = """
|
||||
impl agent.
|
||||
"""
|
||||
|
||||
_AGENT_WITH_REMOTES = """
|
||||
_AGENT_WITH_REPOS = """
|
||||
---
|
||||
bottle: dev
|
||||
git:
|
||||
remotes:
|
||||
h:
|
||||
Name: r
|
||||
Upstream: ssh://x/y.git
|
||||
git-gate:
|
||||
repos:
|
||||
r:
|
||||
url: ssh://git@x/y.git
|
||||
identity: /dev/null
|
||||
---
|
||||
|
||||
bad agent.
|
||||
@@ -199,9 +193,9 @@ _AGENT_WITH_REMOTES = """
|
||||
|
||||
|
||||
class TestAgentGitUserMdLoader(unittest.TestCase):
|
||||
"""Locks the md path: `git` is an accepted agent key and threads
|
||||
into the parsed Agent (not rejected as an unknown frontmatter
|
||||
key), and agent `git.remotes` dies through the same loader."""
|
||||
"""Locks the md path: `git-gate` is an accepted agent key and threads
|
||||
into the parsed Agent (not rejected as an unknown frontmatter key),
|
||||
and agent `git-gate.repos` dies through the same loader."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.home = Path(tempfile.mkdtemp(prefix="cb-home-"))
|
||||
@@ -225,18 +219,18 @@ class TestAgentGitUserMdLoader(unittest.TestCase):
|
||||
self._write("agents/impl.md", _AGENT_WITH_GIT)
|
||||
m = Manifest.resolve(str(self.home))
|
||||
u = m.bottle_for("impl").git_user
|
||||
self.assertEqual("agent-name", u.name) # agent wins
|
||||
self.assertEqual("bottle@example.com", u.email) # bottle falls through
|
||||
self.assertEqual("agent-name", u.name)
|
||||
self.assertEqual("bottle@example.com", u.email)
|
||||
self.assertEqual(
|
||||
"name=agent-name (agent), email=bottle@example.com (bottle)",
|
||||
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("agents/impl.md", _AGENT_WITH_REMOTES)
|
||||
self._write("agents/impl.md", _AGENT_WITH_REPOS)
|
||||
msg = _error_message(Manifest.resolve, str(self.home))
|
||||
self.assertIn("git.remotes", msg)
|
||||
self.assertIn("git-gate.repos", msg)
|
||||
self.assertIn("bottle-only", msg)
|
||||
|
||||
|
||||
|
||||
@@ -113,42 +113,30 @@ class TestExtendsEnvMerge(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."""
|
||||
|
||||
_GIT_ENTRY_A = {
|
||||
"Name": "a",
|
||||
"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",
|
||||
}
|
||||
_GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "identity": "/dev/null"}
|
||||
_GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "identity": "/dev/null"}
|
||||
|
||||
def test_child_git_remotes_merge_with_parent(self):
|
||||
def test_child_git_repos_merge_with_parent(self):
|
||||
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": {"host-b": self._GIT_ENTRY_B}},
|
||||
"git-gate": {"repos": {"b": self._GIT_ENTRY_B}},
|
||||
},
|
||||
)
|
||||
names = [e.Name for e in m.bottles["child"].git]
|
||||
self.assertEqual(["a", "b"], names)
|
||||
|
||||
def test_child_git_remote_replaces_same_host(self):
|
||||
replacement = {
|
||||
"Name": "a2",
|
||||
"Upstream": "ssh://git@host-a/replacement.git",
|
||||
"IdentityFile": "/dev/null",
|
||||
}
|
||||
def test_child_git_repo_replaces_same_host(self):
|
||||
replacement = {"url": "ssh://git@host-a/replacement.git", "identity": "/dev/null"}
|
||||
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": {"host-a": replacement}},
|
||||
"git-gate": {"repos": {"a2": replacement}},
|
||||
},
|
||||
)
|
||||
entries = m.bottles["child"].git
|
||||
@@ -156,30 +144,30 @@ class TestExtendsGitMerge(unittest.TestCase):
|
||||
self.assertEqual("a2", entries[0].Name)
|
||||
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(
|
||||
base={"git": {"remotes": {
|
||||
"host-a": self._GIT_ENTRY_A,
|
||||
"host-b": self._GIT_ENTRY_B,
|
||||
base={"git-gate": {"repos": {
|
||||
"a": self._GIT_ENTRY_A,
|
||||
"b": self._GIT_ENTRY_B,
|
||||
}}},
|
||||
child={"extends": "base"},
|
||||
)
|
||||
names = [e.Name for e in m.bottles["child"].git]
|
||||
self.assertEqual(["a", "b"], names)
|
||||
|
||||
def test_child_explicit_empty_git_clears_parent(self):
|
||||
# `git.remotes: {}` is the documented way to say "drop
|
||||
# the parent's remotes" rather than "inherit them".
|
||||
def test_child_explicit_empty_repos_clears_parent(self):
|
||||
# `git-gate.repos: {}` is the documented way to say "drop
|
||||
# the parent's repos" rather than "inherit them".
|
||||
m = _build(
|
||||
base={"git": {"remotes": {"host-a": self._GIT_ENTRY_A}}},
|
||||
child={"extends": "base", "git": {"remotes": {}}},
|
||||
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
|
||||
child={"extends": "base", "git-gate": {"repos": {}}},
|
||||
)
|
||||
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(
|
||||
base={"git": {"remotes": {"host-a": self._GIT_ENTRY_A}}},
|
||||
child={"extends": "base", "git": {"user": {"name": "Child"}}},
|
||||
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
|
||||
child={"extends": "base", "git-gate": {"user": {"name": "Child"}}},
|
||||
)
|
||||
self.assertEqual(["a"], [e.Name for e in m.bottles["child"].git])
|
||||
self.assertEqual("Child", m.bottles["child"].git_user.name)
|
||||
@@ -209,12 +197,12 @@ class TestExtendsListsFullReplace(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."""
|
||||
|
||||
def test_parent_full_child_omits(self):
|
||||
m = _build(
|
||||
base={"git": {"user": {"name": "Parent", "email": "p@x"}}},
|
||||
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
|
||||
child={"extends": "base"},
|
||||
)
|
||||
u = m.bottles["child"].git_user
|
||||
@@ -223,10 +211,10 @@ class TestExtendsGitUserOverlay(unittest.TestCase):
|
||||
|
||||
def test_child_overrides_both(self):
|
||||
m = _build(
|
||||
base={"git": {"user": {"name": "Parent", "email": "p@x"}}},
|
||||
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
|
||||
child={
|
||||
"extends": "base",
|
||||
"git": {"user": {"name": "Child", "email": "c@x"}},
|
||||
"git-gate": {"user": {"name": "Child", "email": "c@x"}},
|
||||
},
|
||||
)
|
||||
u = m.bottles["child"].git_user
|
||||
@@ -234,11 +222,9 @@ class TestExtendsGitUserOverlay(unittest.TestCase):
|
||||
self.assertEqual("c@x", u.email)
|
||||
|
||||
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(
|
||||
base={"git": {"user": {"name": "Parent"}}},
|
||||
child={"extends": "base", "git": {"user": {"email": "c@x"}}},
|
||||
base={"git-gate": {"user": {"name": "Parent"}}},
|
||||
child={"extends": "base", "git-gate": {"user": {"email": "c@x"}}},
|
||||
)
|
||||
u = m.bottles["child"].git_user
|
||||
self.assertEqual("Parent", u.name)
|
||||
@@ -246,11 +232,10 @@ class TestExtendsGitUserOverlay(unittest.TestCase):
|
||||
|
||||
def test_child_overrides_only_email(self):
|
||||
m = _build(
|
||||
base={"git": {"user": {"name": "Parent", "email": "p@x"}}},
|
||||
child={"extends": "base", "git": {"user": {"email": "c@x"}}},
|
||||
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
|
||||
child={"extends": "base", "git-gate": {"user": {"email": "c@x"}}},
|
||||
)
|
||||
u = m.bottles["child"].git_user
|
||||
# Child overrides email; name inherited from parent.
|
||||
self.assertEqual("Parent", u.name)
|
||||
self.assertEqual("c@x", u.email)
|
||||
|
||||
|
||||
+136
-131
@@ -1,39 +1,25 @@
|
||||
"""Unit: Bottle.git manifest parsing + validation (PRD 0008)."""
|
||||
"""Unit: git-gate.repos manifest parsing + validation (PRD 0047)."""
|
||||
|
||||
import unittest
|
||||
|
||||
from bot_bottle.manifest import ManifestError, Manifest
|
||||
|
||||
|
||||
def _manifest(git_entries):
|
||||
def _manifest(repos: dict) -> dict:
|
||||
return {
|
||||
"bottles": {"dev": {"git": {"remotes": {
|
||||
_host_for(entry): entry for entry in git_entries
|
||||
}}}},
|
||||
"bottles": {"dev": {"git-gate": {"repos": repos}}},
|
||||
"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):
|
||||
def test_parses_minimal_entry(self):
|
||||
m = Manifest.from_json_obj(_manifest([{
|
||||
"Name": "bot-bottle",
|
||||
"Upstream": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
||||
"IdentityFile": "/dev/null",
|
||||
}]))
|
||||
m = Manifest.from_json_obj(_manifest({
|
||||
"bot-bottle": {
|
||||
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
||||
"identity": "/dev/null",
|
||||
},
|
||||
}))
|
||||
entries = m.bottles["dev"].git
|
||||
self.assertEqual(1, len(entries))
|
||||
e = entries[0]
|
||||
@@ -44,138 +30,145 @@ class TestGitEntryParsing(unittest.TestCase):
|
||||
self.assertEqual("didericis/bot-bottle.git", e.UpstreamPath)
|
||||
|
||||
def test_default_port_is_22(self):
|
||||
m = Manifest.from_json_obj(_manifest([{
|
||||
"Name": "foo",
|
||||
"Upstream": "ssh://git@github.com/didericis/foo.git",
|
||||
"IdentityFile": "/dev/null",
|
||||
}]))
|
||||
m = Manifest.from_json_obj(_manifest({
|
||||
"foo": {
|
||||
"url": "ssh://git@github.com/didericis/foo.git",
|
||||
"identity": "/dev/null",
|
||||
},
|
||||
}))
|
||||
e = m.bottles["dev"].git[0]
|
||||
self.assertEqual("22", e.UpstreamPort)
|
||||
self.assertEqual("github.com", e.UpstreamHost)
|
||||
|
||||
def test_known_host_key_optional(self):
|
||||
m = Manifest.from_json_obj(_manifest([{
|
||||
"Name": "foo",
|
||||
"Upstream": "ssh://git@github.com/foo.git",
|
||||
"IdentityFile": "/dev/null",
|
||||
}]))
|
||||
def test_host_key_optional(self):
|
||||
m = Manifest.from_json_obj(_manifest({
|
||||
"foo": {
|
||||
"url": "ssh://git@github.com/foo.git",
|
||||
"identity": "/dev/null",
|
||||
},
|
||||
}))
|
||||
self.assertEqual("", m.bottles["dev"].git[0].KnownHostKey)
|
||||
|
||||
def test_missing_name_dies(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
Manifest.from_json_obj(_manifest([{
|
||||
"Upstream": "ssh://git@github.com/foo.git",
|
||||
"IdentityFile": "/dev/null",
|
||||
}]))
|
||||
def test_host_key_stored(self):
|
||||
m = Manifest.from_json_obj(_manifest({
|
||||
"foo": {
|
||||
"url": "ssh://git@github.com/foo.git",
|
||||
"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):
|
||||
with self.assertRaises(ManifestError):
|
||||
Manifest.from_json_obj(_manifest([{
|
||||
"Name": "foo",
|
||||
"IdentityFile": "/dev/null",
|
||||
}]))
|
||||
def test_repo_name_becomes_Name(self):
|
||||
m = Manifest.from_json_obj(_manifest({
|
||||
"my-repo": {
|
||||
"url": "ssh://git@github.com/foo.git",
|
||||
"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):
|
||||
Manifest.from_json_obj(_manifest([{
|
||||
"Name": "foo",
|
||||
"Upstream": "ssh://git@github.com/foo.git",
|
||||
}]))
|
||||
Manifest.from_json_obj(_manifest({
|
||||
"foo": {"identity": "/dev/null"},
|
||||
}))
|
||||
|
||||
def test_non_ssh_upstream_dies(self):
|
||||
def test_missing_identity_dies(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
Manifest.from_json_obj(_manifest([{
|
||||
"Name": "foo",
|
||||
"Upstream": "https://github.com/didericis/foo.git",
|
||||
"IdentityFile": "/dev/null",
|
||||
}]))
|
||||
Manifest.from_json_obj(_manifest({
|
||||
"foo": {"url": "ssh://git@github.com/foo.git"},
|
||||
}))
|
||||
|
||||
def test_scp_style_upstream_dies(self):
|
||||
# SCP-style "git@host:path" is intentionally not supported in
|
||||
# v1 — ssh:// only.
|
||||
def test_unknown_key_in_entry_dies(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
Manifest.from_json_obj(_manifest([{
|
||||
"Name": "foo",
|
||||
"Upstream": "git@github.com:didericis/foo.git",
|
||||
"IdentityFile": "/dev/null",
|
||||
}]))
|
||||
Manifest.from_json_obj(_manifest({
|
||||
"foo": {
|
||||
"url": "ssh://git@github.com/foo.git",
|
||||
"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):
|
||||
Manifest.from_json_obj(_manifest([{
|
||||
"Name": "foo",
|
||||
"Upstream": "ssh://github.com/foo.git",
|
||||
"IdentityFile": "/dev/null",
|
||||
}]))
|
||||
Manifest.from_json_obj(_manifest({
|
||||
"foo": {
|
||||
"url": "https://github.com/didericis/foo.git",
|
||||
"identity": "/dev/null",
|
||||
},
|
||||
}))
|
||||
|
||||
def test_upstream_without_path_dies(self):
|
||||
def test_scp_style_url_dies(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
Manifest.from_json_obj(_manifest([{
|
||||
"Name": "foo",
|
||||
"Upstream": "ssh://git@github.com",
|
||||
"IdentityFile": "/dev/null",
|
||||
}]))
|
||||
Manifest.from_json_obj(_manifest({
|
||||
"foo": {
|
||||
"url": "git@github.com:didericis/foo.git",
|
||||
"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):
|
||||
with self.assertRaises(ManifestError):
|
||||
Manifest.from_json_obj(_manifest([{
|
||||
"Name": "foo",
|
||||
"Upstream": "ssh://git@github.com:notaport/foo.git",
|
||||
"IdentityFile": "/dev/null",
|
||||
}]))
|
||||
Manifest.from_json_obj(_manifest({
|
||||
"foo": {
|
||||
"url": "ssh://git@github.com:notaport/foo.git",
|
||||
"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):
|
||||
def test_duplicate_name_dies(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
Manifest.from_json_obj({
|
||||
"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):
|
||||
def test_two_repos_different_hosts_both_parsed(self):
|
||||
# Repo names come from dict keys; two distinct keys always produce
|
||||
# two distinct entries (uniqueness is guaranteed at the YAML/dict level).
|
||||
m = Manifest.from_json_obj({
|
||||
"bottles": {"dev": {"git": {"remotes": {
|
||||
"gitea.dideric.is": {
|
||||
"Name": "bot-bottle",
|
||||
"Upstream": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
|
||||
"IdentityFile": "/dev/null",
|
||||
"bottles": {"dev": {"git-gate": {"repos": {
|
||||
"foo": {
|
||||
"url": "ssh://git@a.example/x.git",
|
||||
"identity": "/dev/null",
|
||||
},
|
||||
"bar": {
|
||||
"url": "ssh://git@b.example/y.git",
|
||||
"identity": "/dev/null",
|
||||
},
|
||||
}}}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
e = m.bottles["dev"].git[0]
|
||||
self.assertEqual("gitea.dideric.is", e.RemoteKey)
|
||||
self.assertEqual("100.78.141.42", e.UpstreamHost)
|
||||
self.assertEqual("30009", e.UpstreamPort)
|
||||
names = {e.Name for e in m.bottles["dev"].git}
|
||||
self.assertEqual({"foo", "bar"}, names)
|
||||
|
||||
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):
|
||||
Manifest.from_json_obj({
|
||||
"bottles": {
|
||||
@@ -192,25 +185,37 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
||||
"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({
|
||||
"bottles": {"dev": {}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
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):
|
||||
Manifest.from_json_obj({
|
||||
"bottles": {"dev": {"git": "not-a-list"}},
|
||||
"bottles": {"dev": {"git-gate": "not-a-dict"}},
|
||||
"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({
|
||||
"bottles": {"dev": {"git": {"remotes": {}}}},
|
||||
"bottles": {"dev": {"git-gate": {"repos": {}}}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
self.assertEqual((), m.bottles["dev"].git)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,7 @@ def _error_message(callable_, *args, **kwargs) -> str:
|
||||
|
||||
def _manifest(git_user):
|
||||
return {
|
||||
"bottles": {"dev": {"git": {"user": git_user}}},
|
||||
"bottles": {"dev": {"git-gate": {"user": git_user}}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
}
|
||||
|
||||
@@ -75,13 +75,13 @@ class TestGitUserParsing(unittest.TestCase):
|
||||
msg = _error_message(
|
||||
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):
|
||||
msg = _error_message(
|
||||
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):
|
||||
msg = _error_message(
|
||||
@@ -92,7 +92,7 @@ class TestGitUserParsing(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
self.assertIn("git_user", msg)
|
||||
self.assertIn("git.user", msg)
|
||||
self.assertIn("git-gate.user", msg)
|
||||
|
||||
|
||||
class TestGitUserDirect(unittest.TestCase):
|
||||
|
||||
@@ -69,13 +69,14 @@ class TestGitGateGitconfigRender(unittest.TestCase):
|
||||
'[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({
|
||||
"bottles": {"dev": {"git": {"remotes": {
|
||||
"gitea.dideric.is": {
|
||||
"Name": "bot-bottle",
|
||||
"Upstream": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
|
||||
"IdentityFile": "/dev/null",
|
||||
"bottles": {"dev": {"git-gate": {"repos": {
|
||||
"bot-bottle": {
|
||||
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
|
||||
"identity": "/dev/null",
|
||||
},
|
||||
}}}},
|
||||
"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",
|
||||
out,
|
||||
)
|
||||
self.assertIn(
|
||||
"\tinsteadOf = "
|
||||
"ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
||||
out,
|
||||
)
|
||||
self.assertNotIn("gitea.dideric.is", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -42,11 +42,6 @@ from bot_bottle.supervise import SupervisePlan
|
||||
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(
|
||||
*,
|
||||
@@ -69,20 +64,19 @@ def _plan(
|
||||
guest_env: dict[str, str] | None = None,
|
||||
) -> SmolmachinesBottlePlan:
|
||||
bottle_json: dict = {}
|
||||
git_json: dict = {}
|
||||
git_gate_json: dict = {}
|
||||
if git:
|
||||
git_json["remotes"] = {
|
||||
_remote_host(g): {
|
||||
"Name": g.Name,
|
||||
"Upstream": g.Upstream,
|
||||
"IdentityFile": g.IdentityFile,
|
||||
git_gate_json["repos"] = {
|
||||
g.Name: {
|
||||
"url": g.Upstream,
|
||||
"identity": g.IdentityFile,
|
||||
}
|
||||
for g in git
|
||||
}
|
||||
if git_user is not None:
|
||||
git_json["user"] = git_user
|
||||
if git_json:
|
||||
bottle_json["git"] = git_json
|
||||
git_gate_json["user"] = git_user
|
||||
if git_gate_json:
|
||||
bottle_json["git-gate"] = git_gate_json
|
||||
if supervise:
|
||||
bottle_json["supervise"] = True
|
||||
manifest = Manifest.from_json_obj({
|
||||
|
||||
Reference in New Issue
Block a user