feat: add NetBox plugin store
CI / php-store (push) Waiting to run
CI / python-components (push) Waiting to run

This commit is contained in:
2026-08-24 20:51:25 +02:00
commit f36d6be511
135 changed files with 15160 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
from __future__ import annotations
import hashlib
import json
import sys
import zipfile
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from netbox_store_agent.catalog import HttpStatusError, PluginMetadata, ReleasePlan
from netbox_store_agent.config import (
AgentSettings,
CommandSettings,
Config,
PathSettings,
PolicySettings,
StoreSettings,
)
from netbox_store_agent.errors import ExecutionError
from netbox_store_agent.runner import CommandResult
def make_config(root: Path, *, dry_run: bool = True, require_peers: bool = False) -> Config:
root = root.resolve()
managed_root = root / "netbox"
managed_root.mkdir(parents=True, exist_ok=True)
state = root / "state"
return Config(
agent=AgentSettings(
socket_path=root / "agent.sock",
journal_path=state / "journal.sqlite3",
lock_path=state / "lifecycle.lock",
backup_dir=state / "backups",
dry_run=dry_run,
require_root=False,
require_peer_credentials=require_peers,
allowed_peer_uids=(0,),
allowed_peer_gids=(0,),
socket_mode=0o660,
socket_uid=0,
socket_gid=0,
max_request_bytes=65536,
connection_timeout_seconds=1,
worker_threads=1,
),
store=StoreSettings(
base_url="http://store.test",
plugin_endpoint_template="/api/v1/plugins/{plugin_slug}",
release_endpoint_template="/api/v1/plugins/{plugin_slug}/releases/{version}",
timeout_seconds=1,
max_catalog_bytes=1024 * 1024,
max_artifact_bytes=1024 * 1024,
allow_private_addresses=True,
allow_http_for_testing=True,
allowed_hosts=("store.test", "artifacts.test"),
bearer_token_file=None,
ca_file=None,
),
paths=PathSettings(
allowed_root=managed_root,
include_path=managed_root / "store_plugins.py",
requirements_path=managed_root / "store_requirements.txt",
temp_dir=state / "tmp",
),
commands=CommandSettings(
python_path=root / "bin" / "python",
manage_path=managed_root / "manage.py",
systemctl_path=root / "bin" / "systemctl",
services=("netbox", "netbox-rq"),
command_timeout_seconds=10,
),
policy=PolicySettings(
netbox_version="4.6.8",
min_supported_netbox="4.6.5",
max_supported_netbox="4.6.8",
self_plugin_slugs=("netbox-store", "netbox-plugin-store", "netbox_plugin_store"),
allow_prereleases=False,
require_release_for_enable=True,
),
)
def release_json(version: str = "1.2.3", **overrides: Any) -> dict[str, Any]:
result: dict[str, Any] = {
"version": version,
"download_url": f"http://artifacts.test/demo_plugin-{version}-py3-none-any.whl",
"sha256": "a" * 64,
"artifact_size": 100,
"commit_sha": "",
"min_netbox_version": "4.6.5",
"max_netbox_version": "4.6.8",
"published_at": None,
"approved": True,
"status": "approved",
"immutable": True,
"approved_payload_sha256": "c" * 64,
}
result.update(overrides)
return result
def plugin_json(releases: list[dict[str, Any]] | None = None, **overrides: Any) -> dict[str, Any]:
result: dict[str, Any] = {
"api_version": "v1",
"slug": "demo-plugin",
"name": "Demo",
"summary": "Summary",
"description": "Description",
"repository_url": "https://git.test/demo",
"latest_version": "1.2.3",
"package_name": "demo-plugin",
"import_name": "demo_plugin",
"min_netbox_version": "4.6.5",
"max_netbox_version": "4.6.8",
"approved": True,
"status": "approved",
"releases": releases or [],
}
result.update(overrides)
return result
class FakeTransport:
def __init__(self, responses: dict[str, Any], artifact: bytes | None = None):
self.responses = responses
self.artifact = artifact or b""
self.json_urls: list[str] = []
self.download_urls: list[str] = []
def get_json(self, url: str, max_bytes: int) -> Any:
self.json_urls.append(url)
if url not in self.responses:
raise HttpStatusError(404, "missing")
value = self.responses[url]
if isinstance(value, Exception):
raise value
assert len(json.dumps(value)) <= max_bytes
return value
def download(
self,
url: str,
destination: Path,
*,
max_bytes: int,
expected_size: int,
expected_sha256: str,
) -> None:
self.download_urls.append(url)
if len(self.artifact) != expected_size:
raise AssertionError("fixture size mismatch")
if hashlib.sha256(self.artifact).hexdigest() != expected_sha256:
raise AssertionError("fixture digest mismatch")
destination.write_bytes(self.artifact)
def wheel_bytes(path: Path, version: str = "1.2.3") -> bytes:
wheel = path / f"demo_plugin-{version}-py3-none-any.whl"
with zipfile.ZipFile(wheel, "w") as archive:
archive.writestr("demo_plugin/__init__.py", "")
archive.writestr(
f"demo_plugin-{version}.dist-info/WHEEL",
"Wheel-Version: 1.0\nGenerator: tests\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
)
return wheel.read_bytes()
def plan(version: str = "1.2.3") -> ReleasePlan:
plugin = PluginMetadata(
"demo-plugin", "demo-plugin", "demo_plugin", "4.6.5", "4.6.8", ()
)
return ReleasePlan(
plugin,
version,
f"http://artifacts.test/demo_plugin-{version}-py3-none-any.whl",
f"demo_plugin-{version}-py3-none-any.whl",
"b" * 64,
1,
"c" * 64,
)
class FakeStore:
def __init__(self, release: ReleasePlan | None = None):
self.release = release or plan()
self.calls: list[tuple[str, ...]] = []
def get_plugin(self, slug: str) -> PluginMetadata:
self.calls.append(("plugin", slug))
return self.release.plugin
def get_release(self, slug: str, version: str) -> ReleasePlan:
self.calls.append(("release", slug, version))
return self.release
def download_release(self, release: ReleasePlan, directory: Path) -> Path:
self.calls.append(("download", release.version))
target = directory / release.filename
target.write_bytes(b"x")
return target
class FakeRunner:
def __init__(self, fail_step: str | None = None):
self.commands: list[tuple[str, ...]] = []
self.fail_step = fail_step
def run(self, argv: list[str]) -> CommandResult:
command = tuple(argv)
self.commands.append(command)
if self.fail_step and self.fail_step in command:
raise ExecutionError("injected command failure")
return CommandResult(command, "", "")
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
import hashlib
import tempfile
import unittest
from pathlib import Path
from support import FakeTransport, make_config, plugin_json, release_json, wheel_bytes
from netbox_store_agent.catalog import CatalogError, SecureHTTPTransport, StoreClient
from netbox_store_agent.config import StoreSettings
class CatalogTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
self.config = make_config(self.root)
def tearDown(self) -> None:
self.temporary.cleanup()
def test_trailing_slash_fallback_and_valid_wheel(self) -> None:
artifact = wheel_bytes(self.root)
release = release_json(
sha256=hashlib.sha256(artifact).hexdigest(), artifact_size=len(artifact)
)
responses = {
"http://store.test/api/v1/plugins/demo-plugin/": plugin_json(),
"http://store.test/api/v1/plugins/demo-plugin/releases/1.2.3/": release,
}
transport = FakeTransport(responses, artifact)
client = StoreClient(self.config, transport)
plan = client.get_release("demo-plugin", "1.2.3")
# Destination directory is caller-owned; use an existing operation directory.
operation_dir = self.root / "operation"
operation_dir.mkdir()
downloaded = client.download_release(plan, operation_dir)
self.assertTrue(downloaded.is_file())
self.assertEqual(transport.json_urls[0][-1], "n")
self.assertEqual(transport.json_urls[1][-1], "/")
def test_release_falls_back_to_plugin_releases_array(self) -> None:
release = release_json()
transport = FakeTransport(
{"http://store.test/api/v1/plugins/demo-plugin": plugin_json([release])}
)
plan = StoreClient(self.config, transport).get_release("demo-plugin", "1.2.3")
self.assertEqual(plan.version, "1.2.3")
def test_unapproved_or_mutable_release_rejected(self) -> None:
for change in ({"approved": False}, {"immutable": False}, {"status": "pending"}):
with self.subTest(change=change):
transport = FakeTransport(
{
"http://store.test/api/v1/plugins/demo-plugin": plugin_json(),
"http://store.test/api/v1/plugins/demo-plugin/releases/1.2.3": release_json(
**change
),
}
)
with self.assertRaises(CatalogError):
StoreClient(self.config, transport).get_release("demo-plugin", "1.2.3")
def test_unknown_catalog_field_fails_closed(self) -> None:
payload = plugin_json()
payload["internal_id"] = 7
transport = FakeTransport({"http://store.test/api/v1/plugins/demo-plugin": payload})
with self.assertRaises(CatalogError):
StoreClient(self.config, transport).get_plugin("demo-plugin")
def test_netbox_incompatibility_rejected(self) -> None:
transport = FakeTransport(
{
"http://store.test/api/v1/plugins/demo-plugin": plugin_json(
min_netbox_version="4.7.0"
)
}
)
with self.assertRaises(CatalogError):
StoreClient(self.config, transport).get_plugin("demo-plugin")
def test_wheel_distribution_must_match(self) -> None:
artifact = wheel_bytes(self.root)
release = release_json(
sha256=hashlib.sha256(artifact).hexdigest(), artifact_size=len(artifact)
)
payload = plugin_json(package_name="another-package")
transport = FakeTransport(
{
"http://store.test/api/v1/plugins/demo-plugin": payload,
"http://store.test/api/v1/plugins/demo-plugin/releases/1.2.3": release,
},
artifact,
)
client = StoreClient(self.config, transport)
plan = client.get_release("demo-plugin", "1.2.3")
directory = self.root / "mismatch"
directory.mkdir()
with self.assertRaises(CatalogError):
client.download_release(plan, directory)
def test_bearer_token_is_not_sent_to_artifact_origin(self) -> None:
settings = StoreSettings(
**{
**self.config.store.__dict__,
"base_url": "https://store.test:8443",
"allow_http_for_testing": False,
}
)
transport = SecureHTTPTransport(settings)
transport._token = lambda: "secret" # type: ignore[method-assign]
self.assertEqual(
transport._headers("https://store.test:8443/api")["Authorization"],
"Bearer secret",
)
self.assertNotIn(
"Authorization", transport._headers("https://artifacts.test/release.whl")
)
self.assertNotIn("Authorization", transport._headers("https://store.test/api"))
if __name__ == "__main__":
unittest.main()
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from support import * # noqa: F403
from netbox_store_agent.config import load_config
from netbox_store_agent.errors import ValidationError
class ConfigTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name).resolve()
def tearDown(self) -> None:
self.temporary.cleanup()
@staticmethod
def q(path: Path) -> str:
return json.dumps(path.as_posix())
def config_text(self, **changes: str) -> str:
root = self.root
values = {
"agent_extra": "",
"store_extra": "",
"paths_extra": "",
"commands_extra": "",
"policy_extra": "",
"base_url": '"https://store.test"',
"include": self.q(root / "netbox" / "store_plugins.py"),
}
values.update(changes)
return f"""
[agent]
socket_path = {self.q(root / 'agent.sock')}
journal_path = {self.q(root / 'state' / 'journal.sqlite3')}
lock_path = {self.q(root / 'state' / 'lock')}
backup_dir = {self.q(root / 'state' / 'backups')}
require_root = false
require_peer_credentials = false
{values['agent_extra']}
[store]
base_url = {values['base_url']}
allowed_hosts = ["store.test"]
{values['store_extra']}
[paths]
allowed_root = {self.q(root / 'netbox')}
include_path = {values['include']}
requirements_path = {self.q(root / 'netbox' / 'requirements.txt')}
temp_dir = {self.q(root / 'state' / 'tmp')}
{values['paths_extra']}
[commands]
python_path = {self.q(root / 'bin' / 'python')}
manage_path = {self.q(root / 'netbox' / 'manage.py')}
systemctl_path = {self.q(root / 'bin' / 'systemctl')}
{values['commands_extra']}
[policy]
{values['policy_extra']}
"""
def write(self, text: str) -> Path:
path = self.root / "agent.toml"
path.write_text(text, encoding="utf-8")
path.chmod(0o600)
return path
def test_defaults_are_dry_run_and_block_all_self_slugs(self) -> None:
config = load_config(self.write(self.config_text()), allow_insecure_owner=True)
self.assertTrue(config.agent.dry_run)
self.assertIn("netbox-plugin-store", config.policy.self_plugin_slugs)
self.assertIn("netbox_plugin_store", config.policy.self_plugin_slugs)
def test_unknown_setting_rejected(self) -> None:
with self.assertRaises(ValidationError):
load_config(
self.write(self.config_text(agent_extra="surprise = true")),
allow_insecure_owner=True,
)
def test_http_requires_explicit_testing_switch(self) -> None:
with self.assertRaises(ValidationError):
load_config(
self.write(self.config_text(base_url='"http://store.test"')),
allow_insecure_owner=True,
)
config = load_config(
self.write(
self.config_text(
base_url='"http://store.test"', store_extra="allow_http_for_testing = true"
)
),
allow_insecure_owner=True,
)
self.assertTrue(config.store.allow_http_for_testing)
def test_managed_path_escape_rejected(self) -> None:
with self.assertRaises(ValidationError):
load_config(
self.write(self.config_text(include=self.q(self.root / "outside.py"))),
allow_insecure_owner=True,
)
def test_protocol_size_cannot_exceed_64k(self) -> None:
with self.assertRaises(ValidationError):
load_config(
self.write(self.config_text(agent_extra="max_request_bytes = 65537")),
allow_insecure_owner=True,
)
def test_endpoint_placeholders_are_exact(self) -> None:
with self.assertRaises(ValidationError):
load_config(
self.write(
self.config_text(
store_extra='release_endpoint_template = "/api/{plugin_slug}/{other}"'
)
),
allow_insecure_owner=True,
)
if __name__ == "__main__":
unittest.main()
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
import json
import socket
import tempfile
import unittest
from pathlib import Path
from support import make_config
from netbox_store_agent.daemon import handle_connection
from netbox_store_agent.protocol import response
class FakeService:
def __init__(self) -> None:
self.requests = []
def handle(self, request: object) -> dict[str, object]:
self.requests.append(request)
return response(200, {"ok": True})
class DaemonFramingTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.config = make_config(Path(self.temporary.name), require_peers=False)
def tearDown(self) -> None:
self.temporary.cleanup()
def exchange(self, payload: bytes) -> dict[str, object]:
server, client = socket.socketpair()
try:
client.sendall(payload)
service = FakeService()
handle_connection(server, self.config, service) # type: ignore[arg-type]
data = bytearray()
while b"\n" not in data:
chunk = client.recv(4096)
if not chunk:
break
data.extend(chunk)
self.assertEqual(data.count(b"\n"), 1)
return json.loads(bytes(data).decode())
finally:
client.close()
def test_one_request_one_response(self) -> None:
result = self.exchange(
b'{"protocol_version":1,"method":"GET","path":"/v1/capabilities"}\n'
)
self.assertEqual(result["status"], 200)
self.assertEqual(set(result), {"protocol_version", "status", "body"})
def test_second_json_line_rejected(self) -> None:
result = self.exchange(
b'{"protocol_version":1,"method":"GET","path":"/v1/capabilities"}\n{}\n'
)
self.assertEqual(result["status"], 400)
def test_missing_newline_rejected_when_client_half_closes(self) -> None:
server, client = socket.socketpair()
try:
client.sendall(b"{}")
client.shutdown(socket.SHUT_WR)
service = FakeService()
handle_connection(server, self.config, service) # type: ignore[arg-type]
result = json.loads(client.recv(4096).decode())
self.assertEqual(result["status"], 400)
finally:
client.close()
def test_oversized_frame_rejected(self) -> None:
result = self.exchange(b"x" * 65536 + b"\n")
self.assertEqual(result["status"], 400)
if __name__ == "__main__":
unittest.main()
+177
View File
@@ -0,0 +1,177 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from support import FakeRunner, FakeStore, make_config, plan
from netbox_store_agent.executor import OperationProcessor
from netbox_store_agent.journal import Journal, ManagedPlugin
from netbox_store_agent.protocol import OperationRequest
class ExecutorTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
self.key = "20f4274f-d4e5-42bf-9164-967b1a774481"
def tearDown(self) -> None:
self.temporary.cleanup()
def request(self, action: str, *, version: str | None = None, token: str | None = None) -> OperationRequest:
return OperationRequest(
"eea17d87-8944-4ee2-a076-363338ab746d",
action,
"demo-plugin",
version,
token,
"alice",
)
def seed(self, journal: Journal, *, enabled: bool) -> ManagedPlugin:
release = plan()
plugin = ManagedPlugin(
"demo-plugin",
"demo-plugin",
"demo_plugin",
"1.2.3",
enabled,
(release.requirement(),),
)
journal.upsert_managed_plugin(plugin)
return plugin
def test_dry_run_verifies_artifact_without_runner_or_state(self) -> None:
config = make_config(self.root, dry_run=True)
journal = Journal(config.agent.journal_path)
store = FakeStore()
runner = FakeRunner()
journal.submit(self.key, self.request("install", version="1.2.3", token="c" * 64))
OperationProcessor(config, journal, store=store, runner=runner).process(self.key)
operation = journal.get_operation(self.key)
self.assertEqual(operation["state"], "dry_run")
self.assertIn(("download", "1.2.3"), store.calls)
self.assertEqual(runner.commands, [])
self.assertIsNone(journal.get_managed_plugin("demo-plugin"))
self.assertFalse(config.paths.include_path.exists())
def test_install_is_disabled_and_uses_fixed_pip_argv(self) -> None:
config = make_config(self.root, dry_run=False)
journal = Journal(config.agent.journal_path)
runner = FakeRunner()
journal.submit(self.key, self.request("install", version="1.2.3", token="c" * 64))
OperationProcessor(config, journal, store=FakeStore(), runner=runner).process(self.key)
operation = journal.get_operation(self.key)
self.assertEqual(operation["state"], "succeeded")
managed = journal.get_managed_plugin("demo-plugin")
self.assertIsNotNone(managed)
self.assertFalse(managed.enabled)
self.assertEqual(len(runner.commands), 1)
command = runner.commands[0]
self.assertIn("--no-index", command)
self.assertIn("--no-deps", command)
self.assertIn("--require-hashes", command)
def test_approval_token_mismatch_fails_before_download(self) -> None:
config = make_config(self.root, dry_run=False)
journal = Journal(config.agent.journal_path)
store = FakeStore()
runner = FakeRunner()
journal.submit(self.key, self.request("install", version="1.2.3", token="d" * 64))
OperationProcessor(config, journal, store=store, runner=runner).process(self.key)
self.assertEqual(journal.get_operation(self.key)["state"], "failed")
self.assertFalse(any(call[0] == "download" for call in store.calls))
self.assertEqual(runner.commands, [])
def test_enable_runs_migrate_collectstatic_and_both_service_restart(self) -> None:
config = make_config(self.root, dry_run=False)
journal = Journal(config.agent.journal_path)
self.seed(journal, enabled=False)
runner = FakeRunner()
journal.submit(self.key, self.request("enable"))
OperationProcessor(config, journal, store=FakeStore(), runner=runner).process(self.key)
self.assertEqual(journal.get_operation(self.key)["state"], "succeeded")
self.assertTrue(journal.get_managed_plugin("demo-plugin").enabled)
flattened = [item for command in runner.commands for item in command]
self.assertIn("migrate", flattened)
self.assertIn("collectstatic", flattened)
self.assertIn("netbox", flattened)
self.assertIn("netbox-rq", flattened)
def test_uninstall_requires_disabled(self) -> None:
config = make_config(self.root, dry_run=False)
journal = Journal(config.agent.journal_path)
self.seed(journal, enabled=True)
runner = FakeRunner()
journal.submit(self.key, self.request("uninstall"))
OperationProcessor(config, journal, store=FakeStore(), runner=runner).process(self.key)
self.assertEqual(journal.get_operation(self.key)["state"], "failed")
self.assertEqual(runner.commands, [])
def test_disable_restarts_services_and_persists_disabled_state(self) -> None:
config = make_config(self.root, dry_run=False)
journal = Journal(config.agent.journal_path)
self.seed(journal, enabled=True)
runner = FakeRunner()
journal.submit(self.key, self.request("disable"))
OperationProcessor(config, journal, store=FakeStore(), runner=runner).process(self.key)
self.assertEqual(journal.get_operation(self.key)["state"], "succeeded")
self.assertFalse(journal.get_managed_plugin("demo-plugin").enabled)
self.assertEqual(len(runner.commands), 1)
self.assertIn("restart", runner.commands[0])
def test_uninstall_disabled_plugin_uses_fixed_package_and_deletes_state(self) -> None:
config = make_config(self.root, dry_run=False)
journal = Journal(config.agent.journal_path)
self.seed(journal, enabled=False)
runner = FakeRunner()
journal.submit(self.key, self.request("uninstall"))
OperationProcessor(config, journal, store=FakeStore(), runner=runner).process(self.key)
self.assertEqual(journal.get_operation(self.key)["state"], "succeeded")
self.assertIsNone(journal.get_managed_plugin("demo-plugin"))
self.assertEqual(len(runner.commands), 1)
self.assertEqual(runner.commands[0][-1], "demo-plugin")
def test_failed_pip_attempt_is_conservatively_manual_recovery(self) -> None:
config = make_config(self.root, dry_run=False)
journal = Journal(config.agent.journal_path)
runner = FakeRunner(fail_step="install")
journal.submit(self.key, self.request("install", version="1.2.3", token="c" * 64))
OperationProcessor(config, journal, store=FakeStore(), runner=runner).process(self.key)
self.assertEqual(journal.get_operation(self.key)["state"], "manual_recovery")
def test_failure_after_pip_requires_manual_recovery_and_restores_files(self) -> None:
config = make_config(self.root, dry_run=False)
config.paths.include_path.write_text("old include", encoding="utf-8")
config.paths.requirements_path.write_text("old requirements", encoding="utf-8")
journal = Journal(config.agent.journal_path)
self.seed(journal, enabled=True)
runner = FakeRunner(fail_step="restart")
journal.submit(self.key, self.request("update", version="1.2.3", token="c" * 64))
OperationProcessor(config, journal, store=FakeStore(), runner=runner).process(self.key)
self.assertEqual(journal.get_operation(self.key)["state"], "manual_recovery")
self.assertEqual(config.paths.include_path.read_text(), "old include")
self.assertEqual(config.paths.requirements_path.read_text(), "old requirements")
def test_self_management_fails_before_store_access(self) -> None:
config = make_config(self.root, dry_run=True)
journal = Journal(config.agent.journal_path)
store = FakeStore()
request = OperationRequest(
"eea17d87-8944-4ee2-a076-363338ab746d",
"install",
"netbox-plugin-store",
"1.2.3",
"c" * 64,
"alice",
)
journal.submit(self.key, request)
OperationProcessor(config, journal, store=store, runner=FakeRunner()).process(self.key)
self.assertEqual(journal.get_operation(self.key)["state"], "failed")
self.assertEqual(store.calls, [])
if __name__ == "__main__":
unittest.main()
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from support import * # noqa: F403
from netbox_store_agent.errors import ConflictError
from netbox_store_agent.journal import Journal, ManagedPlugin
from netbox_store_agent.protocol import OperationRequest
class JournalTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.journal = Journal(Path(self.temporary.name) / "journal.sqlite3")
self.key = "20f4274f-d4e5-42bf-9164-967b1a774481"
self.request = OperationRequest(
"eea17d87-8944-4ee2-a076-363338ab746d",
"install",
"demo-plugin",
"1.2.3",
"opaque",
"alice",
)
def tearDown(self) -> None:
self.temporary.cleanup()
def test_idempotency_same_payload_returns_existing(self) -> None:
first, created = self.journal.submit(self.key, self.request)
second, created_again = self.journal.submit(self.key, self.request)
self.assertTrue(created)
self.assertFalse(created_again)
self.assertEqual(first["operation_id"], second["operation_id"])
def test_idempotency_conflict(self) -> None:
self.journal.submit(self.key, self.request)
changed = OperationRequest(**{**self.request.as_dict(), "requested_by": "mallory"})
with self.assertRaises(ConflictError):
self.journal.submit(self.key, changed)
def test_claim_is_exactly_once(self) -> None:
self.journal.submit(self.key, self.request)
self.assertTrue(self.journal.claim(self.key))
self.assertFalse(self.journal.claim(self.key))
def test_running_recovery_is_manual(self) -> None:
self.journal.submit(self.key, self.request)
self.journal.claim(self.key)
self.assertEqual(self.journal.recover_interrupted(), 1)
operation = self.journal.get_operation(self.key)
self.assertEqual(operation["state"], "manual_recovery")
def test_managed_plugin_round_trip(self) -> None:
plugin = ManagedPlugin(
"demo-plugin",
"demo-plugin",
"demo_plugin",
"1.2.3",
False,
({"sha256": "a" * 64},),
)
self.journal.upsert_managed_plugin(plugin)
self.assertEqual(self.journal.get_managed_plugin("demo-plugin"), plugin)
self.journal.delete_managed_plugin("demo-plugin")
self.assertIsNone(self.journal.get_managed_plugin("demo-plugin"))
if __name__ == "__main__":
unittest.main()
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from support import make_config, plan
from netbox_store_agent.errors import PolicyError
from netbox_store_agent.journal import ManagedPlugin
from netbox_store_agent.managed_files import ManagedFiles
class ManagedFilesTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
self.config = make_config(self.root, dry_run=False)
self.files = ManagedFiles(self.config)
release = plan()
self.plugin = ManagedPlugin(
"demo-plugin",
"demo-plugin",
"demo_plugin",
"1.2.3",
True,
(release.requirement(),),
)
def tearDown(self) -> None:
self.temporary.cleanup()
def test_include_exports_constant_without_referencing_plugins(self) -> None:
text = self.files.render_include([self.plugin]).decode()
self.assertIn('STORE_PLUGINS = [\n "demo_plugin"\n]', text)
self.assertNotIn("PLUGINS = list", text)
def test_atomic_write_backup_and_restore(self) -> None:
self.config.paths.include_path.write_text("old include", encoding="utf-8")
self.config.paths.requirements_path.write_text("old requirements", encoding="utf-8")
snapshot = self.files.write(
"20f4274f-d4e5-42bf-9164-967b1a774481", [self.plugin]
)
self.assertIn("STORE_PLUGINS", self.config.paths.include_path.read_text())
self.assertIn("--hash=sha256:", self.config.paths.requirements_path.read_text())
self.files.restore(snapshot)
self.assertEqual(self.config.paths.include_path.read_text(), "old include")
self.assertEqual(self.config.paths.requirements_path.read_text(), "old requirements")
def test_conflicting_locked_distribution_rejected(self) -> None:
other_requirement = {**self.plugin.requirements[0], "version": "2.0.0"}
other = ManagedPlugin(
"other", "demo-plugin", "other_plugin", "2.0.0", False, (other_requirement,)
)
with self.assertRaises(PolicyError):
self.files.render_requirements([self.plugin, other])
def test_requirement_host_must_be_allowlisted(self) -> None:
requirement = {**self.plugin.requirements[0], "download_url": "http://evil.test/x.whl"}
plugin = ManagedPlugin(
"demo-plugin", "demo-plugin", "demo_plugin", "1.2.3", False, (requirement,)
)
with self.assertRaises(Exception):
self.files.render_requirements([plugin])
def test_path_escape_rejected(self) -> None:
escaped = self.root / "outside.py"
changed = self.config.__class__(
self.config.agent,
self.config.store,
self.config.paths.__class__(
self.config.paths.allowed_root,
escaped,
self.config.paths.requirements_path,
self.config.paths.temp_dir,
),
self.config.commands,
self.config.policy,
)
with self.assertRaises(PolicyError):
ManagedFiles(changed).write(
"20f4274f-d4e5-42bf-9164-967b1a774481", [self.plugin]
)
if __name__ == "__main__":
unittest.main()
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
import json
import unittest
from support import * # noqa: F403
from netbox_store_agent.errors import ValidationError
from netbox_store_agent.protocol import decode_request, encode_response, parse_request, response
class ProtocolTests(unittest.TestCase):
def submit(self, **body_overrides: object) -> dict[str, object]:
body: dict[str, object] = {
"request_id": "eea17d87-8944-4ee2-a076-363338ab746d",
"action": "install",
"plugin_slug": "demo-plugin",
"version": "1.2.3",
"approved_payload_sha256": "c" * 64,
"requested_by": "netbox:alice",
}
body.update(body_overrides)
return {
"protocol_version": 1,
"method": "POST",
"path": "/v1/operations",
"idempotency_key": "20f4274f-d4e5-42bf-9164-967b1a774481",
"body": body,
}
def test_exact_submit_contract_and_opaque_token(self) -> None:
request = parse_request(self.submit())
self.assertEqual(request.operation.approved_payload_sha256, "c" * 64)
def test_unknown_top_level_field_rejected(self) -> None:
value = self.submit(extra=True)
value["unexpected"] = True
with self.assertRaises(ValidationError):
parse_request(value)
def test_unknown_body_field_rejected(self) -> None:
with self.assertRaises(ValidationError):
parse_request(self.submit(extra=True))
def test_non_lifecycle_fields_must_be_null(self) -> None:
with self.assertRaises(ValidationError):
parse_request(self.submit(action="disable"))
request = parse_request(
self.submit(action="disable", version=None, approved_payload_sha256=None)
)
self.assertEqual(request.operation.action, "disable")
def test_noncanonical_uuid_rejected(self) -> None:
with self.assertRaises(ValidationError):
parse_request(self.submit(request_id="EEA17D87-8944-4EE2-A076-363338AB746D"))
def test_multiple_lines_and_oversize_rejected(self) -> None:
raw = json.dumps(self.submit()).encode()
with self.assertRaises(ValidationError):
decode_request(raw + b"\n{}", 65536)
with self.assertRaises(ValidationError):
decode_request(b"x" * 10, 9)
def test_capabilities_and_status_paths(self) -> None:
cap = parse_request({"protocol_version": 1, "method": "GET", "path": "/v1/capabilities"})
self.assertEqual(cap.path, "/v1/capabilities")
status = parse_request(
{
"protocol_version": 1,
"method": "GET",
"path": "/v1/operations/20f4274f-d4e5-42bf-9164-967b1a774481",
}
)
self.assertEqual(status.idempotency_key, "20f4274f-d4e5-42bf-9164-967b1a774481")
def test_response_shape_and_single_line(self) -> None:
encoded = encode_response(response(200, {"ok": True}))
self.assertEqual(encoded.count(b"\n"), 1)
self.assertEqual(set(json.loads(encoded)), {"protocol_version", "status", "body"})
if __name__ == "__main__":
unittest.main()