feat: add NetBox plugin store
This commit is contained in:
@@ -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, "", "")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user