Unify identity/provisioned_key into key block #235

Merged
didericis merged 5 commits from refactor-key-block into main 2026-06-19 18:31:10 -04:00
11 changed files with 218 additions and 173 deletions
+22 -16
View File
@@ -389,13 +389,12 @@ def _provision_dynamic_key(
Returns the host-side path to the private key file so the caller Returns the host-side path to the private key file so the caller
can inject it into the GitGateUpstream as `identity_file`.""" can inject it into the GitGateUpstream as `identity_file`."""
from .deploy_key_provisioner import get_provisioner from .deploy_key_provisioner import get_provisioner
pk = entry.ProvisionedKey pk = entry.Key
assert pk is not None token = os.environ.get(pk.forge_token_env)
token = os.environ.get(pk.token_env)
if token is None: if token is None:
raise RuntimeError( raise RuntimeError(
f"git-gate.repos[{entry.Name!r}] provisioned_key.token_env" f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
f" = {pk.token_env!r}: env var is not set" f" = {pk.forge_token_env!r}: env var is not set"
) )
api_url = pk.api_url or f"https://{entry.UpstreamHost}" api_url = pk.api_url or f"https://{entry.UpstreamHost}"
provisioner = get_provisioner(pk.provider, token, api_url) provisioner = get_provisioner(pk.provider, token, api_url)
@@ -428,18 +427,18 @@ def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) ->
address manually.""" address manually."""
from .deploy_key_provisioner import get_provisioner from .deploy_key_provisioner import get_provisioner
for entry in bottle.git: for entry in bottle.git:
if entry.ProvisionedKey is None: if entry.Key.provider != "gitea":
continue continue
pk = entry.ProvisionedKey pk = entry.Key
id_file = stage_dir / f"{entry.Name}-deploy-key-id" id_file = stage_dir / f"{entry.Name}-deploy-key-id"
if not id_file.exists(): if not id_file.exists():
continue continue
key_id = id_file.read_text().strip() key_id = id_file.read_text().strip()
token = os.environ.get(pk.token_env) token = os.environ.get(pk.forge_token_env)
if token is None: if token is None:
raise RuntimeError( raise RuntimeError(
f"git-gate.repos[{entry.Name!r}] provisioned_key.token_env" f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
f" = {pk.token_env!r}: env var is not set;" f" = {pk.forge_token_env!r}: env var is not set;"
f" cannot revoke deploy key {key_id}" f" cannot revoke deploy key {key_id}"
) )
api_url = pk.api_url or f"https://{entry.UpstreamHost}" api_url = pk.api_url or f"https://{entry.UpstreamHost}"
@@ -452,6 +451,14 @@ def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) ->
info(f"revoked deploy key {key_id} for git-gate.repos[{entry.Name!r}]") info(f"revoked deploy key {key_id} for git-gate.repos[{entry.Name!r}]")
def _resolve_identity_file(entry: ManifestGitEntry, slug: str, stage_dir: Path) -> str:
"""Return the host-side SSH identity file path for this entry.
For gitea entries, provisions a fresh deploy key first."""
if entry.Key.provider == "gitea":
return _provision_dynamic_key(entry, slug, stage_dir)
return entry.IdentityFile
class GitGate(ABC): class GitGate(ABC):
"""The per-agent git-gate. Encapsulates the host-side prepare """The per-agent git-gate. Encapsulates the host-side prepare
(upstream lift + entrypoint/hook render); the sidecar's (upstream lift + entrypoint/hook render); the sidecar's
@@ -463,7 +470,7 @@ class GitGate(ABC):
entrypoint, pre-receive hook, and access-hook scripts (mode entrypoint, pre-receive hook, and access-hook scripts (mode
600) under `stage_dir`. Pure host-side, no docker subprocess. 600) under `stage_dir`. Pure host-side, no docker subprocess.
For `provisioned_key` entries, also generates and registers For `gitea` key entries, also generates and registers
a fresh deploy key via the forge API and writes the private key a fresh deploy key via the forge API and writes the private key
+ key ID to `stage_dir`. + key ID to `stage_dir`.
@@ -472,11 +479,10 @@ class GitGate(ABC):
before passing the plan to `.start`.""" before passing the plan to `.start`."""
upstreams_list = list(git_gate_upstreams_for_bottle(bottle)) upstreams_list = list(git_gate_upstreams_for_bottle(bottle))
for i, entry in enumerate(bottle.git): for i, entry in enumerate(bottle.git):
if entry.ProvisionedKey is not None: upstreams_list[i] = dataclasses.replace(
key_file = _provision_dynamic_key(entry, slug, stage_dir) upstreams_list[i],
upstreams_list[i] = dataclasses.replace( identity_file=_resolve_identity_file(entry, slug, stage_dir),
upstreams_list[i], identity_file=key_file )
)
upstreams = tuple(upstreams_list) upstreams = tuple(upstreams_list)
entrypoint = stage_dir / "git_gate_entrypoint.sh" entrypoint = stage_dir / "git_gate_entrypoint.sh"
entrypoint.write_text(git_gate_render_entrypoint(upstreams)) entrypoint.write_text(git_gate_render_entrypoint(upstreams))
+2 -1
View File
@@ -56,7 +56,7 @@ from .manifest_egress import (
ManifestEgressConfig, ManifestEgressConfig,
ManifestEgressRoute, ManifestEgressRoute,
) )
from .manifest_git import ManifestGitEntry, ManifestGitUser, parse_git_gate_config from .manifest_git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig, parse_git_gate_config
from .manifest_schema import BOTTLE_KEYS from .manifest_schema import BOTTLE_KEYS
# Re-export everything that callers currently import from this module. # Re-export everything that callers currently import from this module.
@@ -64,6 +64,7 @@ __all__ = [
"ManifestError", "ManifestError",
"ManifestGitEntry", "ManifestGitEntry",
"ManifestGitUser", "ManifestGitUser",
"ManifestKeyConfig",
"ManifestAgentProvider", "ManifestAgentProvider",
"EGRESS_AUTH_SCHEMES", "EGRESS_AUTH_SCHEMES",
"ManifestEgressRoute", "ManifestEgressRoute",
+73 -66
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional
from .manifest_util import ManifestError, as_json_object from .manifest_util import ManifestError, as_json_object
@@ -13,6 +12,8 @@ from .manifest_util import ManifestError, as_json_object
# defence; this regex is belt-and-suspenders and documents intent). # defence; this regex is belt-and-suspenders and documents intent).
_GIT_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") _GIT_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
_KEY_PROVIDERS = {"static", "gitea"}
def _opt_str(value: object, label: str) -> str: def _opt_str(value: object, label: str) -> str:
if value is None: if value is None:
@@ -69,20 +70,22 @@ def validate_unique_git_names(bottle_name: str, git: tuple[ManifestGitEntry, ...
@dataclass(frozen=True) @dataclass(frozen=True)
class ManifestProvisionedKeyConfig: class ManifestKeyConfig:
"""Configuration for automatic deploy-key lifecycle management """Configuration for a repo's SSH key in git-gate.repos.
(PRD 0048). Used when a git-gate.repos entry opts out of a
static identity file and instead wants a fresh SSH keypair
generated at spin-up and revoked at teardown.
`provider` names the contrib sub-package to load (e.g. `gitea`). `provider` is either `"static"` (a pre-existing key on the host) or
`token_env` is the name of a host-side env var carrying the API `"gitea"` (automatic deploy-key lifecycle via the Gitea API).
token; the value is read at provision time, never stored on the
plan. `api_url` is the forge's HTTP API root; if empty, it is For `static`: `path` is the host-side absolute path to the SSH private key.
derived from the upstream URL's host at provision time."""
For `gitea`: `forge_token_env` is the name of a host-side env var
carrying the Gitea API token; the value is read at provision time,
never stored on the plan. `api_url` is the forge's HTTP API root; if
empty, it is derived from the upstream URL's host at provision time."""
provider: str provider: str
token_env: str path: str = ""
forge_token_env: str = ""
api_url: str = "" api_url: str = ""
@@ -99,15 +102,16 @@ class ManifestGitEntry:
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.
Manifest source: `git-gate.repos.<Name>` (PRD 0047/0048). Exactly Manifest source: `git-gate.repos.<Name>` (PRD 0047/0048). A `key`
one of `identity` (static key path) or `provisioned_key` (automatic block is required; `key.provider` is `"static"` or `"gitea"`. For
lifecycle) must be present. The internal field names are stable.""" `static`, `IdentityFile` is populated at parse time from `key.path`.
For `gitea`, `IdentityFile` is populated at provision time."""
Name: str Name: str
Upstream: str Upstream: str
Key: ManifestKeyConfig = ManifestKeyConfig(provider="")
IdentityFile: str = "" IdentityFile: str = ""
KnownHostKey: str = "" KnownHostKey: str = ""
ProvisionedKey: Optional[ManifestProvisionedKeyConfig] = None
RemoteKey: str = "" RemoteKey: str = ""
UpstreamUser: str = "" UpstreamUser: str = ""
UpstreamHost: str = "" UpstreamHost: str = ""
@@ -120,8 +124,8 @@ class ManifestGitEntry:
) -> "ManifestGitEntry": ) -> "ManifestGitEntry":
"""Parse one entry from `git-gate.repos.<repo_name>`. """Parse one entry from `git-gate.repos.<repo_name>`.
YAML keys: `url` (required), exactly one of `identity` or YAML keys: `url` (required), `key` (required object with
`provisioned_key` (required), `host_key` (optional). `provider`, and provider-specific fields), `host_key` (optional).
The repo_name becomes `Name`.""" The repo_name becomes `Name`."""
if not repo_name: if not repo_name:
raise ManifestError( raise ManifestError(
@@ -135,10 +139,10 @@ class ManifestGitEntry:
label = f"git-gate.repos[{repo_name!r}]" label = f"git-gate.repos[{repo_name!r}]"
d = as_json_object(raw, f"bottle '{bottle_name}' {label}") d = as_json_object(raw, f"bottle '{bottle_name}' {label}")
for k in d: for k in d:
if k not in {"url", "identity", "provisioned_key", "host_key"}: if k not in {"url", "key", "host_key"}:
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' {label} has unknown key {k!r}; " f"bottle '{bottle_name}' {label} has unknown key {k!r}; "
f"allowed: url, identity, provisioned_key, host_key" f"allowed: url, key, host_key"
) )
upstream = d.get("url") upstream = d.get("url")
if not isinstance(upstream, str) or not upstream: if not isinstance(upstream, str) or not upstream:
@@ -146,32 +150,13 @@ class ManifestGitEntry:
f"bottle '{bottle_name}' {label} missing required string field 'url'" f"bottle '{bottle_name}' {label} missing required string field 'url'"
) )
has_identity = "identity" in d if "key" not in d:
has_provisioned = "provisioned_key" in d
if has_identity and has_provisioned:
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' {label} must set exactly one of " f"bottle '{bottle_name}' {label} missing required 'key' block"
f"'identity' or 'provisioned_key'; got both."
)
if not has_identity and not has_provisioned:
raise ManifestError(
f"bottle '{bottle_name}' {label} must set exactly one of "
f"'identity' or 'provisioned_key'; got neither."
) )
key_config = _parse_key_config(bottle_name, label, d["key"])
ident = "" ident = key_config.path if key_config.provider == "static" else ""
provisioned_key: Optional[ManifestProvisionedKeyConfig] = None
if has_identity:
raw_ident = d.get("identity")
if not isinstance(raw_ident, str) or not raw_ident:
raise ManifestError(
f"bottle '{bottle_name}' {label} 'identity' must be a non-empty string"
)
ident = raw_ident
else:
provisioned_key = _parse_provisioned_key_config(
bottle_name, label, d["provisioned_key"]
)
khk = _opt_str( khk = _opt_str(
d.get("host_key"), d.get("host_key"),
@@ -183,9 +168,9 @@ class ManifestGitEntry:
return cls( return cls(
Name=repo_name, Name=repo_name,
Upstream=upstream, Upstream=upstream,
Key=key_config,
IdentityFile=ident, IdentityFile=ident,
KnownHostKey=khk, KnownHostKey=khk,
ProvisionedKey=provisioned_key,
RemoteKey=host, RemoteKey=host,
UpstreamUser=user, UpstreamUser=user,
UpstreamHost=host, UpstreamHost=host,
@@ -194,38 +179,60 @@ class ManifestGitEntry:
) )
def _parse_provisioned_key_config( def _parse_key_config(
bottle_name: str, label: str, raw: object bottle_name: str, label: str, raw: object
) -> ManifestProvisionedKeyConfig: ) -> ManifestKeyConfig:
d = as_json_object(raw, f"bottle '{bottle_name}' {label}.provisioned_key") d = as_json_object(raw, f"bottle '{bottle_name}' {label}.key")
for k in d:
if k not in {"provider", "token_env", "api_url"}:
raise ManifestError(
f"bottle '{bottle_name}' {label}.provisioned_key has unknown key {k!r}; "
f"allowed: provider, token_env, api_url"
)
provider = d.get("provider") provider = d.get("provider")
if not isinstance(provider, str) or not provider: if not isinstance(provider, str) or not provider:
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' {label}.provisioned_key missing required " f"bottle '{bottle_name}' {label}.key missing required "
f"string field 'provider'" f"string field 'provider'"
) )
token_env = d.get("token_env") if provider not in _KEY_PROVIDERS:
if not isinstance(token_env, str) or not token_env:
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' {label}.provisioned_key missing required " f"bottle '{bottle_name}' {label}.key provider {provider!r} is unknown; "
f"string field 'token_env'" f"allowed: {', '.join(sorted(_KEY_PROVIDERS))}"
) )
api_url_raw = d.get("api_url", "")
if not isinstance(api_url_raw, str): if provider == "gitea":
for k in d:
if k not in {"provider", "forge_token_env", "api_url"}:
raise ManifestError(
f"bottle '{bottle_name}' {label}.key has unknown key {k!r} "
f"for provider 'gitea'; allowed: provider, forge_token_env, api_url"
)
forge_token_env = d.get("forge_token_env")
if not isinstance(forge_token_env, str) or not forge_token_env:
raise ManifestError(
f"bottle '{bottle_name}' {label}.key missing required "
f"string field 'forge_token_env' for provider 'gitea'"
)
api_url_raw = d.get("api_url", "")
if not isinstance(api_url_raw, str):
raise ManifestError(
f"bottle '{bottle_name}' {label}.key 'api_url' must be a string"
)
return ManifestKeyConfig(
provider=provider,
forge_token_env=forge_token_env,
api_url=api_url_raw,
)
# provider == "static"
for k in d:
if k not in {"provider", "path"}:
raise ManifestError(
f"bottle '{bottle_name}' {label}.key has unknown key {k!r} "
f"for provider 'static'; allowed: provider, path"
)
path = d.get("path")
if not isinstance(path, str) or not path:
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' {label}.provisioned_key 'api_url' must be a string" f"bottle '{bottle_name}' {label}.key missing required "
f"string field 'path' for provider 'static'"
) )
return ManifestProvisionedKeyConfig( return ManifestKeyConfig(provider=provider, path=path)
provider=provider,
token_env=token_env,
api_url=api_url_raw,
)
@dataclass(frozen=True) @dataclass(frozen=True)
+2 -2
View File
@@ -46,12 +46,12 @@ def fixture_with_git_dict() -> dict[str, Any]:
"repos": { "repos": {
"bot-bottle": { "bot-bottle": {
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git", "url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
"host_key": "ssh-ed25519 AAAA...", "host_key": "ssh-ed25519 AAAA...",
}, },
"foo": { "foo": {
"url": "ssh://git@github.com/didericis/foo.git", "url": "ssh://git@github.com/didericis/foo.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
"host_key": "ssh-ed25519 BBBB...", "host_key": "ssh-ed25519 BBBB...",
}, },
}, },
+1 -1
View File
@@ -51,7 +51,7 @@ def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest
bottle["git-gate"] = {"repos": { bottle["git-gate"] = {"repos": {
"upstream": { "upstream": {
"url": "ssh://git@example.com:22/x/y.git", "url": "ssh://git@example.com:22/x/y.git",
"identity": "/etc/hostname", # any existing file "key": {"provider": "static", "path": "/etc/hostname"},
}, },
}} }}
if with_egress: if with_egress:
+1 -1
View File
@@ -284,7 +284,7 @@ class TestPrepare(unittest.TestCase):
"bottles": {"dev": {"git-gate": {"repos": { "bottles": {"dev": {"git-gate": {"repos": {
"foo": { "foo": {
"url": "ssh://git@github.com/didericis/foo.git", "url": "ssh://git@github.com/didericis/foo.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
}}}}, }}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
+1 -1
View File
@@ -112,7 +112,7 @@ class TestAgentGitUserOverlay(unittest.TestCase):
class TestAgentGitUserRejections(unittest.TestCase): class TestAgentGitUserRejections(unittest.TestCase):
def test_agent_repos_dies_bottle_only(self): def test_agent_repos_dies_bottle_only(self):
msg = _error_message(_manifest, agent_git={ msg = _error_message(_manifest, agent_git={
"repos": {"r": {"url": "ssh://git@x/y.git", "identity": "/dev/null"}}, "repos": {"r": {"url": "ssh://git@x/y.git", "key": {"provider": "static", "path": "/dev/null"}}},
}) })
self.assertIn("git-gate.repos", msg) self.assertIn("git-gate.repos", msg)
self.assertIn("bottle-only", msg) self.assertIn("bottle-only", msg)
+3 -3
View File
@@ -116,8 +116,8 @@ class TestExtendsGitMerge(unittest.TestCase):
"""git-gate.user overlays by field; git-gate.repos 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 = {"url": "ssh://git@host-a/a.git", "identity": "/dev/null"} _GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "key": {"provider": "static", "path": "/dev/null"}}
_GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "identity": "/dev/null"} _GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "key": {"provider": "static", "path": "/dev/null"}}
def test_child_git_repos_merge_with_parent(self): def test_child_git_repos_merge_with_parent(self):
m = _build( m = _build(
@@ -131,7 +131,7 @@ class TestExtendsGitMerge(unittest.TestCase):
self.assertEqual(["a", "b"], names) self.assertEqual(["a", "b"], names)
def test_child_git_repo_replaces_same_host(self): def test_child_git_repo_replaces_same_host(self):
replacement = {"url": "ssh://git@host-a/replacement.git", "identity": "/dev/null"} replacement = {"url": "ssh://git@host-a/replacement.git", "key": {"provider": "static", "path": "/dev/null"}}
m = _build( m = _build(
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}}, base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
child={ child={
+109 -79
View File
@@ -17,7 +17,7 @@ class TestGitEntryParsing(unittest.TestCase):
m = Manifest.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"bot-bottle": { "bot-bottle": {
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git", "url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
})) }))
entries = m.bottles["dev"].git entries = m.bottles["dev"].git
@@ -33,7 +33,7 @@ class TestGitEntryParsing(unittest.TestCase):
m = Manifest.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/didericis/foo.git", "url": "ssh://git@github.com/didericis/foo.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
})) }))
e = m.bottles["dev"].git[0] e = m.bottles["dev"].git[0]
@@ -44,7 +44,7 @@ class TestGitEntryParsing(unittest.TestCase):
m = Manifest.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
})) }))
self.assertEqual("", m.bottles["dev"].git[0].KnownHostKey) self.assertEqual("", m.bottles["dev"].git[0].KnownHostKey)
@@ -53,7 +53,7 @@ class TestGitEntryParsing(unittest.TestCase):
m = Manifest.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
"host_key": "ssh-ed25519 AAAA", "host_key": "ssh-ed25519 AAAA",
}, },
})) }))
@@ -63,7 +63,7 @@ class TestGitEntryParsing(unittest.TestCase):
m = Manifest.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"my-repo": { "my-repo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
})) }))
self.assertEqual("my-repo", m.bottles["dev"].git[0].Name) self.assertEqual("my-repo", m.bottles["dev"].git[0].Name)
@@ -71,10 +71,10 @@ class TestGitEntryParsing(unittest.TestCase):
def test_missing_url_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({
"foo": {"identity": "/dev/null"}, "foo": {"key": {"provider": "static", "path": "/dev/null"}},
})) }))
def test_missing_identity_dies(self): def test_missing_key_block_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": {"url": "ssh://git@github.com/foo.git"}, "foo": {"url": "ssh://git@github.com/foo.git"},
@@ -85,7 +85,7 @@ class TestGitEntryParsing(unittest.TestCase):
Manifest.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
"IdentityFile": "/dev/null", # old PascalCase key "IdentityFile": "/dev/null", # old PascalCase key
}, },
})) }))
@@ -95,7 +95,7 @@ class TestGitEntryParsing(unittest.TestCase):
Manifest.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "https://github.com/didericis/foo.git", "url": "https://github.com/didericis/foo.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
})) }))
@@ -104,7 +104,7 @@ class TestGitEntryParsing(unittest.TestCase):
Manifest.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "git@github.com:didericis/foo.git", "url": "git@github.com:didericis/foo.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
})) }))
@@ -113,7 +113,7 @@ class TestGitEntryParsing(unittest.TestCase):
Manifest.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://github.com/foo.git", "url": "ssh://github.com/foo.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
})) }))
@@ -122,7 +122,7 @@ class TestGitEntryParsing(unittest.TestCase):
Manifest.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com", "url": "ssh://git@github.com",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
})) }))
@@ -131,7 +131,7 @@ class TestGitEntryParsing(unittest.TestCase):
Manifest.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com:notaport/foo.git", "url": "ssh://git@github.com:notaport/foo.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
})) }))
@@ -139,7 +139,7 @@ class TestGitEntryParsing(unittest.TestCase):
m = Manifest.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"bot-bottle": { "bot-bottle": {
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git", "url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
})) }))
e = m.bottles["dev"].git[0] e = m.bottles["dev"].git[0]
@@ -156,11 +156,11 @@ class TestGitEntryCrossValidation(unittest.TestCase):
"bottles": {"dev": {"git-gate": {"repos": { "bottles": {"dev": {"git-gate": {"repos": {
"foo": { "foo": {
"url": "ssh://git@a.example/x.git", "url": "ssh://git@a.example/x.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
"bar": { "bar": {
"url": "ssh://git@b.example/y.git", "url": "ssh://git@b.example/y.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
}}}}, }}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
@@ -190,7 +190,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
Manifest.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"o'reilly": { "o'reilly": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
})) }))
@@ -199,7 +199,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
Manifest.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"my repo": { "my repo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
})) }))
@@ -208,7 +208,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
Manifest.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo;bar": { "foo;bar": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
})) }))
@@ -217,7 +217,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
Manifest.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo$bar": { "foo$bar": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
})) }))
@@ -225,7 +225,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
m = Manifest.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"my.repo-name_1": { "my.repo-name_1": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
})) }))
self.assertEqual("my.repo-name_1", m.bottles["dev"].git[0].Name) self.assertEqual("my.repo-name_1", m.bottles["dev"].git[0].Name)
@@ -243,111 +243,141 @@ class TestGitEntryCrossValidation(unittest.TestCase):
self.assertIn("PRD 0047", msg) self.assertIn("PRD 0047", msg)
class TestProvisionedKey(unittest.TestCase): class TestStaticKey(unittest.TestCase):
"""git-gate.repos entries that use provisioned_key (PRD 0048).""" """git-gate.repos entries with key.provider = "static"."""
def test_provisioned_key_minimal(self): def test_static_key_minimal(self):
m = Manifest.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"bot-bottle": { "bot-bottle": {
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git", "url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
"provisioned_key": { "key": {"provider": "static", "path": "/home/user/.ssh/id_ed25519"},
},
}))
e = m.bottles["dev"].git[0]
self.assertEqual("bot-bottle", e.Name)
self.assertEqual("static", e.Key.provider)
self.assertEqual("/home/user/.ssh/id_ed25519", e.Key.path)
self.assertEqual("/home/user/.ssh/id_ed25519", e.IdentityFile)
def test_static_key_sets_identity_file_at_parse_time(self):
m = Manifest.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"},
},
}))
self.assertEqual("/dev/null", m.bottles["dev"].git[0].IdentityFile)
def test_static_key_missing_path_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"key": {"provider": "static"},
},
}))
def test_static_key_unknown_field_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null", "api_url": "x"},
},
}))
class TestGiteaKey(unittest.TestCase):
"""git-gate.repos entries with key.provider = "gitea"."""
def test_gitea_key_minimal(self):
m = Manifest.from_json_obj(_manifest({
"bot-bottle": {
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
"key": {
"provider": "gitea", "provider": "gitea",
"token_env": "GITEA_TOKEN", "forge_token_env": "GITEA_TOKEN",
}, },
}, },
})) }))
e = m.bottles["dev"].git[0] e = m.bottles["dev"].git[0]
self.assertEqual("bot-bottle", e.Name) self.assertEqual("bot-bottle", e.Name)
self.assertIsNotNone(e.ProvisionedKey) self.assertEqual("gitea", e.Key.provider)
assert e.ProvisionedKey is not None self.assertEqual("GITEA_TOKEN", e.Key.forge_token_env)
self.assertEqual("gitea", e.ProvisionedKey.provider) self.assertEqual("", e.Key.api_url)
self.assertEqual("GITEA_TOKEN", e.ProvisionedKey.token_env)
self.assertEqual("", e.ProvisionedKey.api_url)
self.assertEqual("", e.IdentityFile) self.assertEqual("", e.IdentityFile)
def test_provisioned_key_with_api_url(self): def test_gitea_key_with_api_url(self):
m = Manifest.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"repo": { "repo": {
"url": "ssh://git@gitea.example.com/org/repo.git", "url": "ssh://git@gitea.example.com/org/repo.git",
"provisioned_key": { "key": {
"provider": "gitea", "provider": "gitea",
"token_env": "MY_TOKEN", "forge_token_env": "MY_TOKEN",
"api_url": "https://gitea.example.com", "api_url": "https://gitea.example.com",
}, },
}, },
})) }))
pk = m.bottles["dev"].git[0].ProvisionedKey self.assertEqual("https://gitea.example.com", m.bottles["dev"].git[0].Key.api_url)
assert pk is not None
self.assertEqual("https://gitea.example.com", pk.api_url)
def test_both_identity_and_provisioned_key_dies(self): def test_gitea_key_has_no_identity_file_at_parse_time(self):
with self.assertRaises(ManifestError) as ctx: m = Manifest.from_json_obj(_manifest({
Manifest.from_json_obj(_manifest({ "foo": {
"foo": { "url": "ssh://git@github.com/didericis/foo.git",
"url": "ssh://git@github.com/foo.git", "key": {"provider": "gitea", "forge_token_env": "T"},
"identity": "/dev/null", },
"provisioned_key": {"provider": "gitea", "token_env": "T"}, }))
}, self.assertEqual("", m.bottles["dev"].git[0].IdentityFile)
}))
self.assertIn("exactly one of", str(ctx.exception))
self.assertIn("got both", str(ctx.exception))
def test_neither_identity_nor_provisioned_key_dies(self): def test_gitea_key_missing_forge_token_env_dies(self):
with self.assertRaises(ManifestError) as ctx:
Manifest.from_json_obj(_manifest({
"foo": {"url": "ssh://git@github.com/foo.git"},
}))
self.assertIn("exactly one of", str(ctx.exception))
self.assertIn("got neither", str(ctx.exception))
def test_unknown_key_in_provisioned_key_block_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"provisioned_key": { "key": {"provider": "gitea"},
},
}))
def test_gitea_key_unknown_field_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"key": {
"provider": "gitea", "provider": "gitea",
"token_env": "T", "forge_token_env": "T",
"key_type": "rsa", # not allowed "key_type": "rsa", # not allowed
}, },
}, },
})) }))
class TestKeyBlockValidation(unittest.TestCase):
"""Validation rules on the key block shared across providers."""
def test_missing_provider_dies(self): def test_missing_provider_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"provisioned_key": {"token_env": "T"}, "key": {"path": "/dev/null"},
}, },
})) }))
def test_missing_token_env_dies(self): def test_unknown_provider_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"provisioned_key": {"provider": "gitea"}, "key": {"provider": "github"},
}, },
})) }))
def test_provisioned_key_entry_has_no_identity_file(self): def test_missing_key_block_dies(self):
m = Manifest.from_json_obj(_manifest({ with self.assertRaises(ManifestError):
"foo": { Manifest.from_json_obj(_manifest({
"url": "ssh://git@github.com/didericis/foo.git", "foo": {"url": "ssh://git@github.com/foo.git"},
"provisioned_key": {"provider": "gitea", "token_env": "T"}, }))
},
}))
self.assertEqual("", m.bottles["dev"].git[0].IdentityFile)
def test_identity_entry_has_no_provisioned_key(self):
m = Manifest.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"identity": "/dev/null",
},
}))
self.assertIsNone(m.bottles["dev"].git[0].ProvisionedKey)
class TestEmptyGitGateField(unittest.TestCase): class TestEmptyGitGateField(unittest.TestCase):
+1 -1
View File
@@ -76,7 +76,7 @@ class TestGitGateGitconfigRender(unittest.TestCase):
"bottles": {"dev": {"git-gate": {"repos": { "bottles": {"dev": {"git-gate": {"repos": {
"bot-bottle": { "bot-bottle": {
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git", "url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
"identity": "/dev/null", "key": {"provider": "static", "path": "/dev/null"},
}, },
}}}}, }}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
+3 -2
View File
@@ -33,7 +33,7 @@ from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
from bot_bottle.backend.util import AGENT_CA_PATH from bot_bottle.backend.util import AGENT_CA_PATH
from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import ManifestGitEntry, Manifest from bot_bottle.manifest import ManifestGitEntry, ManifestKeyConfig, Manifest
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
@@ -100,7 +100,7 @@ def _plan(
git_gate_json["repos"] = { git_gate_json["repos"] = {
g.Name: { g.Name: {
"url": g.Upstream, "url": g.Upstream,
"identity": g.IdentityFile, "key": {"provider": g.Key.provider or "static", "path": g.Key.path or g.IdentityFile},
} }
for g in git for g in git
} }
@@ -360,6 +360,7 @@ class TestProvisionGit(unittest.TestCase):
git=[ManifestGitEntry( git=[ManifestGitEntry(
Name="bot-bottle", Name="bot-bottle",
Upstream="ssh://git@host/repo.git", Upstream="ssh://git@host/repo.git",
Key=ManifestKeyConfig(provider="static", path="~/.ssh/id_ed25519"),
IdentityFile="~/.ssh/id_ed25519", IdentityFile="~/.ssh/id_ed25519",
)], )],
stage_dir=self.stage, stage_dir=self.stage,