feat(prd-0048): implement SSH deploy-key provisioning with contrib/gitea
- manifest_git.py: add ProvisionedKeyConfig dataclass; extend GitEntry with ProvisionedKey field (optional); make IdentityFile default to "" so provisioned_key entries can be constructed without a static path; add _parse_provisioned_key_config; update from_repos_entry to accept provisioned_key as an alternative to identity (mutually exclusive, parser rejects both-or-neither) - deploy_key_provisioner.py (new): DeployKeyProvisioner ABC with create() and delete() abstract methods; get_provisioner() factory with lazy contrib import for gitea - contrib/gitea/deploy_key_provisioner.py (new): GiteaDeployKeyProvisioner generating ed25519 keypairs via ssh-keygen and managing them through the Gitea deploy-key API (POST/DELETE); 404 on delete is success; all other errors raise RuntimeError - git_gate.py: add _provision_dynamic_key() called in GitGate.prepare() for entries with ProvisionedKey — generates key, writes private key and key ID files to stage_dir, patches GitGateUpstream.identity_file; add revoke_git_gate_provisioned_keys() for teardown — raises on failure - docker/launch.py: call revoke_git_gate_provisioned_keys() in teardown() after stack.close() so revocation runs after containers stop and failures propagate (not suppressed) - smolmachines/launch.py: extract _teardown_smolmachines() helper that catches stack.close() errors (warn + re-raise) then calls revocation; same fatal-on-failure contract as docker backend - test_manifest_git.py: 9 new cases for provisioned_key parsing - test_deploy_key_provisioner.py (new): factory smoke tests - test_contrib_gitea_deploy_key.py (new): create/delete/error/split tests Closes #169
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
"""Unit: GiteaDeployKeyProvisioner (PRD 0048, contrib/gitea)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
import urllib.error
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from tempfile import mkdtemp
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
from bot_bottle.contrib.gitea.deploy_key_provisioner import (
|
||||
GiteaDeployKeyProvisioner,
|
||||
_split_owner_repo,
|
||||
)
|
||||
|
||||
|
||||
def _provisioner() -> GiteaDeployKeyProvisioner:
|
||||
return GiteaDeployKeyProvisioner(
|
||||
token="test-token", api_url="https://gitea.example.com"
|
||||
)
|
||||
|
||||
|
||||
def _urlopen_response(body: dict, status: int = 200) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps(body).encode()
|
||||
resp.status = status
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
|
||||
|
||||
def _http_error(code: int, body: str = "") -> urllib.error.HTTPError:
|
||||
return urllib.error.HTTPError(
|
||||
url="http://x",
|
||||
code=code,
|
||||
msg="err",
|
||||
hdrs=None, # type: ignore[arg-type]
|
||||
fp=BytesIO(body.encode()),
|
||||
)
|
||||
|
||||
|
||||
class TestCreate(unittest.TestCase):
|
||||
def test_create_calls_ssh_keygen_and_posts_to_api(self):
|
||||
provisioner = _provisioner()
|
||||
fake_key_id = 42
|
||||
fake_private = b"PRIVATE_KEY"
|
||||
fake_public = "ssh-ed25519 AAAA fake"
|
||||
|
||||
with patch(
|
||||
"bot_bottle.contrib.gitea.deploy_key_provisioner.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"bot_bottle.contrib.gitea.deploy_key_provisioner.urllib.request.urlopen"
|
||||
) as mock_urlopen, patch(
|
||||
"bot_bottle.contrib.gitea.deploy_key_provisioner.Path.read_bytes",
|
||||
return_value=fake_private,
|
||||
), patch(
|
||||
"bot_bottle.contrib.gitea.deploy_key_provisioner.Path.read_text",
|
||||
return_value=fake_public + "\n",
|
||||
):
|
||||
mock_urlopen.return_value = _urlopen_response({"id": fake_key_id})
|
||||
key_id, private_bytes = provisioner.create(
|
||||
"didericis/bot-bottle", "bot-bottle:slug:repo"
|
||||
)
|
||||
|
||||
# ssh-keygen called with ed25519
|
||||
mock_run.assert_called_once()
|
||||
run_args = mock_run.call_args.args[0]
|
||||
self.assertIn("ssh-keygen", run_args)
|
||||
self.assertIn("-t", run_args)
|
||||
self.assertIn("ed25519", run_args)
|
||||
|
||||
# POST body contains public key
|
||||
post_call = mock_urlopen.call_args.args[0]
|
||||
payload = json.loads(post_call.data)
|
||||
self.assertEqual(fake_public, payload["key"])
|
||||
self.assertFalse(payload["read_only"])
|
||||
|
||||
# Correct URL
|
||||
self.assertIn(
|
||||
"/api/v1/repos/didericis/bot-bottle/keys", post_call.full_url
|
||||
)
|
||||
self.assertEqual(str(fake_key_id), key_id)
|
||||
self.assertEqual(fake_private, private_bytes)
|
||||
|
||||
def test_create_raises_on_http_error(self):
|
||||
provisioner = _provisioner()
|
||||
with patch(
|
||||
"bot_bottle.contrib.gitea.deploy_key_provisioner.subprocess.run"
|
||||
), patch(
|
||||
"bot_bottle.contrib.gitea.deploy_key_provisioner.urllib.request.urlopen",
|
||||
side_effect=_http_error(403, "forbidden"),
|
||||
), patch(
|
||||
"bot_bottle.contrib.gitea.deploy_key_provisioner.Path.read_bytes",
|
||||
return_value=b"pk",
|
||||
), patch(
|
||||
"bot_bottle.contrib.gitea.deploy_key_provisioner.Path.read_text",
|
||||
return_value="ssh-ed25519 AAAA\n",
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
provisioner.create("owner/repo", "title")
|
||||
self.assertIn("403", str(ctx.exception))
|
||||
|
||||
|
||||
class TestDelete(unittest.TestCase):
|
||||
def test_delete_calls_correct_endpoint(self):
|
||||
provisioner = _provisioner()
|
||||
with patch(
|
||||
"bot_bottle.contrib.gitea.deploy_key_provisioner.urllib.request.urlopen"
|
||||
) as mock_urlopen:
|
||||
mock_urlopen.return_value = _urlopen_response({})
|
||||
provisioner.delete("didericis/bot-bottle", "99")
|
||||
|
||||
req = mock_urlopen.call_args.args[0]
|
||||
self.assertIn("/api/v1/repos/didericis/bot-bottle/keys/99", req.full_url)
|
||||
self.assertEqual("DELETE", req.get_method())
|
||||
|
||||
def test_delete_tolerates_404(self):
|
||||
provisioner = _provisioner()
|
||||
with patch(
|
||||
"bot_bottle.contrib.gitea.deploy_key_provisioner.urllib.request.urlopen",
|
||||
side_effect=_http_error(404),
|
||||
):
|
||||
provisioner.delete("owner/repo", "123") # must not raise
|
||||
|
||||
def test_delete_raises_on_non_404_http_error(self):
|
||||
provisioner = _provisioner()
|
||||
with patch(
|
||||
"bot_bottle.contrib.gitea.deploy_key_provisioner.urllib.request.urlopen",
|
||||
side_effect=_http_error(500, "internal server error"),
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
provisioner.delete("owner/repo", "7")
|
||||
self.assertIn("500", str(ctx.exception))
|
||||
|
||||
def test_delete_raises_on_url_error(self):
|
||||
provisioner = _provisioner()
|
||||
with patch(
|
||||
"bot_bottle.contrib.gitea.deploy_key_provisioner.urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("connection refused"),
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
provisioner.delete("owner/repo", "7")
|
||||
self.assertIn("connection refused", str(ctx.exception))
|
||||
|
||||
|
||||
class TestSplitOwnerRepo(unittest.TestCase):
|
||||
def test_simple(self):
|
||||
self.assertEqual(("owner", "repo"), _split_owner_repo("owner/repo"))
|
||||
|
||||
def test_raises_on_missing_slash(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_split_owner_repo("noslash")
|
||||
|
||||
def test_raises_on_empty_owner(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_split_owner_repo("/repo")
|
||||
|
||||
def test_raises_on_empty_repo(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_split_owner_repo("owner/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Unit: deploy_key_provisioner factory (PRD 0048)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.deploy_key_provisioner import DeployKeyProvisioner, get_provisioner
|
||||
from bot_bottle.manifest import ManifestError
|
||||
|
||||
|
||||
class TestGetProvisioner(unittest.TestCase):
|
||||
def test_gitea_returns_gitea_provisioner(self):
|
||||
from bot_bottle.contrib.gitea.deploy_key_provisioner import (
|
||||
GiteaDeployKeyProvisioner,
|
||||
)
|
||||
p = get_provisioner("gitea", token="tok", api_url="https://gitea.example.com")
|
||||
self.assertIsInstance(p, GiteaDeployKeyProvisioner)
|
||||
self.assertIsInstance(p, DeployKeyProvisioner)
|
||||
|
||||
def test_unknown_provider_raises_manifest_error(self):
|
||||
with self.assertRaises(ManifestError) as ctx:
|
||||
get_provisioner("github", token="tok", api_url="https://github.com")
|
||||
self.assertIn("github", str(ctx.exception))
|
||||
self.assertIn("provisioned_key provider", str(ctx.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -243,6 +243,113 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
||||
self.assertIn("PRD 0047", msg)
|
||||
|
||||
|
||||
class TestProvisionedKey(unittest.TestCase):
|
||||
"""git-gate.repos entries that use provisioned_key (PRD 0048)."""
|
||||
|
||||
def test_provisioned_key_minimal(self):
|
||||
m = Manifest.from_json_obj(_manifest({
|
||||
"bot-bottle": {
|
||||
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
||||
"provisioned_key": {
|
||||
"provider": "gitea",
|
||||
"token_env": "GITEA_TOKEN",
|
||||
},
|
||||
},
|
||||
}))
|
||||
e = m.bottles["dev"].git[0]
|
||||
self.assertEqual("bot-bottle", e.Name)
|
||||
self.assertIsNotNone(e.ProvisionedKey)
|
||||
assert e.ProvisionedKey is not None
|
||||
self.assertEqual("gitea", e.ProvisionedKey.provider)
|
||||
self.assertEqual("GITEA_TOKEN", e.ProvisionedKey.token_env)
|
||||
self.assertEqual("", e.ProvisionedKey.api_url)
|
||||
self.assertEqual("", e.IdentityFile)
|
||||
|
||||
def test_provisioned_key_with_api_url(self):
|
||||
m = Manifest.from_json_obj(_manifest({
|
||||
"repo": {
|
||||
"url": "ssh://git@gitea.example.com/org/repo.git",
|
||||
"provisioned_key": {
|
||||
"provider": "gitea",
|
||||
"token_env": "MY_TOKEN",
|
||||
"api_url": "https://gitea.example.com",
|
||||
},
|
||||
},
|
||||
}))
|
||||
pk = m.bottles["dev"].git[0].ProvisionedKey
|
||||
assert pk is not None
|
||||
self.assertEqual("https://gitea.example.com", pk.api_url)
|
||||
|
||||
def test_both_identity_and_provisioned_key_dies(self):
|
||||
with self.assertRaises(ManifestError) as ctx:
|
||||
Manifest.from_json_obj(_manifest({
|
||||
"foo": {
|
||||
"url": "ssh://git@github.com/foo.git",
|
||||
"identity": "/dev/null",
|
||||
"provisioned_key": {"provider": "gitea", "token_env": "T"},
|
||||
},
|
||||
}))
|
||||
self.assertIn("exactly one of", str(ctx.exception))
|
||||
self.assertIn("got both", str(ctx.exception))
|
||||
|
||||
def test_neither_identity_nor_provisioned_key_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):
|
||||
Manifest.from_json_obj(_manifest({
|
||||
"foo": {
|
||||
"url": "ssh://git@github.com/foo.git",
|
||||
"provisioned_key": {
|
||||
"provider": "gitea",
|
||||
"token_env": "T",
|
||||
"key_type": "rsa", # not allowed
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
def test_missing_provider_dies(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
Manifest.from_json_obj(_manifest({
|
||||
"foo": {
|
||||
"url": "ssh://git@github.com/foo.git",
|
||||
"provisioned_key": {"token_env": "T"},
|
||||
},
|
||||
}))
|
||||
|
||||
def test_missing_token_env_dies(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
Manifest.from_json_obj(_manifest({
|
||||
"foo": {
|
||||
"url": "ssh://git@github.com/foo.git",
|
||||
"provisioned_key": {"provider": "gitea"},
|
||||
},
|
||||
}))
|
||||
|
||||
def test_provisioned_key_entry_has_no_identity_file(self):
|
||||
m = Manifest.from_json_obj(_manifest({
|
||||
"foo": {
|
||||
"url": "ssh://git@github.com/didericis/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):
|
||||
def test_no_git_gate_field_yields_empty_tuple(self):
|
||||
m = Manifest.from_json_obj({
|
||||
|
||||
Reference in New Issue
Block a user