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
+41
View File
@@ -0,0 +1,41 @@
import sys
import types
if "netbox.plugins" not in sys.modules:
netbox = types.ModuleType("netbox")
plugins = types.ModuleType("netbox.plugins")
class PluginConfig:
def ready(self):
return None
class PluginMenu:
def __init__(self, label, groups, icon_class=None):
self.label = label
self.groups = groups
self.icon_class = icon_class
class PluginMenuItem:
def __init__(
self,
link,
link_text,
auth_required=False,
staff_only=False,
permissions=None,
buttons=None,
):
self.link = link
self.link_text = link_text
self.auth_required = auth_required
self.staff_only = staff_only
self.permissions = permissions or []
self.buttons = buttons or []
plugins.PluginConfig = PluginConfig
plugins.PluginMenu = PluginMenu
plugins.PluginMenuItem = PluginMenuItem
netbox.plugins = plugins
sys.modules["netbox"] = netbox
sys.modules["netbox.plugins"] = plugins
@@ -0,0 +1,34 @@
ALLOWED_HOSTS = ["localhost"]
SECRET_KEY = "netbox-plugin-store-compatibility-test-key-2026-08-24!"
API_TOKEN_PEPPERS = {
1: "netbox-plugin-store-compatibility-test-pepper-2026-08-24!",
}
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "netbox_compatibility_test",
"USER": "netbox",
"PASSWORD": "unused",
"HOST": "127.0.0.1",
"PORT": 5432,
"CONN_MAX_AGE": 0,
}
}
REDIS = {
"tasks": {"HOST": "127.0.0.1", "PORT": 6379, "DATABASE": 0},
"caching": {"HOST": "127.0.0.1", "PORT": 6379, "DATABASE": 1},
}
PLUGINS = ["netbox_plugin_store"]
PLUGINS_CONFIG = {
"netbox_plugin_store": {
"store_url": "https://store.example",
"allowed_store_urls": ["https://store.example"],
"allowed_artifact_urls": ["https://store.example"],
}
}
CENSUS_REPORTING_ENABLED = False
RELEASE_CHECK_URL = None
@@ -0,0 +1,130 @@
"""Smoke tests executed in an environment containing an official NetBox tag."""
from __future__ import annotations
import inspect
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
NETBOX_SOURCE = Path(os.environ["NETBOX_SOURCE"]).resolve()
EXPECTED_VERSION = os.environ["NETBOX_EXPECTED_VERSION"]
sys.path.insert(0, str(NETBOX_SOURCE / "netbox"))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
os.environ.setdefault("NETBOX_CONFIGURATION", "netbox_test_configuration")
import django # noqa: E402
django.setup()
from django.apps import apps # noqa: E402
from django.db.migrations.autodetector import MigrationAutodetector # noqa: E402
from django.db.migrations.loader import MigrationLoader # noqa: E402
from django.db.migrations.questioner import MigrationQuestioner # noqa: E402
from django.db.migrations.state import ProjectState # noqa: E402
from django.template.loader import get_template # noqa: E402
from django.urls import resolve, reverse # noqa: E402
from core.models import Job # noqa: E402
from netbox.jobs import JobRunner # noqa: E402
from netbox_plugin_store import config # noqa: E402
from netbox_plugin_store.jobs import PluginLifecycleJob # noqa: E402
from netbox_plugin_store.navigation import menu # noqa: E402
from users.models import User # noqa: E402
class OfficialNetBoxCompatibilityTests(unittest.TestCase):
def test_exact_netbox_and_django_runtime(self):
from django.conf import settings
self.assertEqual(settings.RELEASE.version, EXPECTED_VERSION)
self.assertGreaterEqual(sys.version_info, (3, 12))
self.assertEqual(django.get_version(), "6.0.7" if EXPECTED_VERSION == "4.6.5" else "6.0.8")
def test_plugin_config_is_registered(self):
app = apps.get_app_config("netbox_plugin_store")
self.assertIsInstance(app, config)
self.assertEqual(config.min_version, "4.6.5")
self.assertEqual(config.max_version, "4.6.8")
def test_jobrunner_queue_metadata_and_run_arguments_are_compatible(self):
self.assertIn("queue_name", inspect.signature(Job.enqueue).parameters)
self.assertTrue(issubclass(PluginLifecycleJob, JobRunner))
with patch.object(Job, "enqueue", return_value="queued") as enqueue:
result = PluginLifecycleJob.enqueue(
user="operator",
queue_name="compatibility",
audit_id=12,
slug="example-plugin",
action="install",
version="1.0.0",
dry_run=True,
actor_id=34,
)
self.assertEqual(result, "queued")
args, kwargs = enqueue.call_args
self.assertIs(args[0].__func__, PluginLifecycleJob.handle.__func__)
self.assertEqual(kwargs["queue_name"], "compatibility")
self.assertEqual(kwargs["audit_id"], 12)
self.assertEqual(kwargs["actor_id"], 34)
def test_navigation_urls_and_permission_semantics(self):
self.assertFalse(hasattr(User(), "is_staff"))
urls = {
"catalog": reverse("plugins:netbox_plugin_store:catalog"),
"status": reverse("plugins:netbox_plugin_store:status"),
"audit-list": reverse("plugins:netbox_plugin_store:audit-list"),
"audit-detail": reverse("plugins:netbox_plugin_store:audit-detail", kwargs={"pk": 7}),
"plugin-detail": reverse(
"plugins:netbox_plugin_store:plugin-detail", kwargs={"slug": "example-plugin"}
),
"lifecycle-confirm": reverse(
"plugins:netbox_plugin_store:lifecycle-confirm",
kwargs={"slug": "example-plugin", "action": "install"},
),
"lifecycle-action": reverse(
"plugins:netbox_plugin_store:lifecycle-action",
kwargs={"slug": "example-plugin", "action": "install"},
),
}
for name, url in urls.items():
self.assertEqual(resolve(url).url_name, name)
items = [item for group in menu.groups for item in group.items]
self.assertEqual(len(items), 3)
for item in items:
self.assertTrue(item.auth_required)
self.assertFalse(item.staff_only)
self.assertEqual(item.permissions, ["netbox_plugin_store.manage_plugin"])
def test_templates_resolve_and_compile_from_installed_wheel(self):
for name in (
"audit_detail.html",
"audit_list.html",
"catalog.html",
"confirm.html",
"detail.html",
"status.html",
):
template = get_template(f"netbox_plugin_store/{name}")
self.assertIsNotNone(template.template)
self.assertIsNotNone(get_template("base/layout.html").template)
def test_migration_graph_and_model_state_have_no_drift(self):
loader = MigrationLoader(None, ignore_no_migrations=True)
self.assertIn(("netbox_plugin_store", "0001_initial"), loader.disk_migrations)
from_state = loader.project_state()
to_state = ProjectState.from_apps(apps)
changes = MigrationAutodetector(
from_state,
to_state,
MigrationQuestioner(specified_apps={"netbox_plugin_store"}),
).changes(graph=loader.graph, trim_to_apps={"netbox_plugin_store"})
self.assertNotIn("netbox_plugin_store", changes)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,81 @@
import json
import subprocess
import unittest
from pathlib import Path
from unittest.mock import patch
from uuid import UUID
import _bootstrap # noqa: F401
from netbox_plugin_store.agent import AgentClient
from netbox_plugin_store.commands import SubprocessRunner
class FakeSocket:
def __init__(self, response):
self.response = response
self.sent = b""
def __enter__(self):
return self
def __exit__(self, *args):
return None
def settimeout(self, timeout):
self.timeout = timeout
def connect(self, path):
self.path = path
def sendall(self, value):
self.sent += value
def shutdown(self, how):
return None
def recv(self, size):
value, self.response = self.response, b""
return value
class AgentTests(unittest.TestCase):
def test_json_line_operation_protocol(self):
operation_id = "12345678-1234-5678-1234-567812345678"
response = (
json.dumps({"protocol_version": 1, "status": 202, "body": {"operation_id": operation_id}}).encode()
+ b"\n"
)
fake = FakeSocket(response)
with (
patch("netbox_plugin_store.agent.socket.AF_UNIX", 1, create=True),
patch("netbox_plugin_store.agent.socket.SOCK_STREAM", 1),
patch("netbox_plugin_store.agent.socket.socket", return_value=fake),
):
result = AgentClient(Path("/run/store.sock")).submit_operation(
request_id="request-id",
action="install",
plugin_slug="example-plugin",
version="1.0.0",
approved_payload_sha256="c" * 64,
requested_by="netbox-user:1",
)
self.assertEqual(result, UUID(operation_id))
request = json.loads(fake.sent.decode().strip())
self.assertEqual(request["method"], "POST")
self.assertEqual(request["path"], "/v1/operations")
self.assertEqual(request["body"]["plugin_slug"], "example-plugin")
UUID(request["idempotency_key"])
class CommandTests(unittest.TestCase):
def test_subprocess_boundary_never_uses_shell(self):
completed = subprocess.CompletedProcess(["python", "-V"], 0, "Python", "")
with patch("netbox_plugin_store.commands.subprocess.run", return_value=completed) as run:
result = SubprocessRunner().run(["python", "-V"], timeout=5)
self.assertEqual(result.stdout, "Python")
self.assertIs(run.call_args.kwargs["shell"], False)
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
import hashlib
import io
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import _bootstrap # noqa: F401
from netbox_plugin_store.client import CatalogPlugin, StoreClient, StoreClientError, URLPolicy
def plugin_payload():
artifact = b"verified artifact"
return {
"slug": "example-plugin",
"name": "Example",
"summary": "Example plugin",
"description": "README",
"repository_url": "https://git.mrblake.cc/team/example",
"latest_version": "1.2.0",
"package_name": "netbox-example",
"import_name": "netbox_example",
"min_netbox_version": "4.6.5",
"max_netbox_version": "4.6.8",
"approved": True,
"releases": [
{
"version": "1.2.0",
"download_url": "https://store.example/artifacts/example.whl",
"sha256": hashlib.sha256(artifact).hexdigest(),
"approved_payload_sha256": "a" * 64,
"approved": True,
"immutable": True,
"min_netbox_version": "4.6.5",
"max_netbox_version": "4.6.8",
}
],
}, artifact
class URLPolicyTests(unittest.TestCase):
def test_exact_origin_and_path_prefix(self):
policy = URLPolicy(["https://store.example/internal"])
self.assertEqual(
policy.check("https://store.example/internal/api/v1/plugins/"),
"https://store.example/internal/api/v1/plugins/",
)
with self.assertRaises(StoreClientError):
policy.check("https://store.example.evil/internal")
with self.assertRaises(StoreClientError):
policy.check("https://store.example/other")
def test_release_selection_checks_netbox_version(self):
payload, _ = plugin_payload()
plugin = CatalogPlugin.from_mapping(payload)
self.assertEqual(plugin.select_release("4.6.8").version, "1.2.0")
with self.assertRaises(StoreClientError):
plugin.select_release("4.6.9")
class _Response(io.BytesIO):
def __init__(self, body):
super().__init__(body)
self.headers = {"Content-Length": str(len(body))}
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
class _Opener:
def __init__(self, body):
self.body = body
def open(self, request, timeout):
return _Response(self.body)
class DownloadTests(unittest.TestCase):
def test_download_is_hashed_before_use(self):
_, artifact = plugin_payload()
client = StoreClient(
"https://store.example", ("https://store.example",), ("https://store.example",)
)
with tempfile.TemporaryDirectory() as temp_name:
target = Path(temp_name) / "plugin.whl"
with patch("netbox_plugin_store.client.build_opener", return_value=_Opener(artifact)):
actual, size = client.download_artifact(
"https://store.example/artifacts/example.whl",
target,
expected_sha256=hashlib.sha256(artifact).hexdigest(),
timeout=10,
max_bytes=1_000,
)
self.assertEqual(actual, hashlib.sha256(artifact).hexdigest())
self.assertEqual(size, len(artifact))
self.assertEqual(target.read_bytes(), artifact)
def test_mismatched_hash_removes_download(self):
_, artifact = plugin_payload()
client = StoreClient(
"https://store.example", ("https://store.example",), ("https://store.example",)
)
with tempfile.TemporaryDirectory() as temp_name:
target = Path(temp_name) / "plugin.whl"
with patch("netbox_plugin_store.client.build_opener", return_value=_Opener(artifact)):
with self.assertRaises(StoreClientError):
client.download_artifact(
"https://store.example/artifacts/example.whl",
target,
expected_sha256="0" * 64,
timeout=10,
max_bytes=1_000,
)
self.assertFalse(target.exists())
if __name__ == "__main__":
unittest.main()
+52
View File
@@ -0,0 +1,52 @@
import tempfile
import unittest
from pathlib import Path
import _bootstrap # noqa: F401
from netbox_plugin_store.editors import EditorError, PluginConfigurationEditor, RequirementsEditor
class ConfigurationEditorTests(unittest.TestCase):
def test_atomic_change_backup_and_rollback(self):
with tempfile.TemporaryDirectory() as temp_name:
root = Path(temp_name)
config = root / "configuration.py"
original = "SECRET_KEY = 'unchanged'\nPLUGINS = [\n 'netbox_plugin_store',\n]\n"
config.write_text(original, encoding="utf-8")
editor = PluginConfigurationEditor(config, root / "backups", 5)
receipt = editor.set_enabled("netbox_example", True)
self.assertTrue(receipt.changed)
self.assertTrue(receipt.backup_path.is_file())
self.assertIn("'netbox_example'", config.read_text(encoding="utf-8"))
editor.rollback(receipt)
self.assertEqual(config.read_text(encoding="utf-8"), original)
def test_dynamic_plugins_assignment_is_rejected(self):
with tempfile.TemporaryDirectory() as temp_name:
root = Path(temp_name)
config = root / "configuration.py"
config.write_text("PLUGINS = load_plugins()\n", encoding="utf-8")
editor = PluginConfigurationEditor(config, root / "backups", 5)
with self.assertRaises(EditorError):
editor.set_enabled("netbox_example", True)
class RequirementsEditorTests(unittest.TestCase):
def test_pin_and_remove_preserve_other_lines(self):
with tempfile.TemporaryDirectory() as temp_name:
root = Path(temp_name)
requirements = root / "local_requirements.txt"
requirements.write_text("# managed manually\nother-package==2\n", encoding="utf-8")
editor = RequirementsEditor(requirements, root / "backups", 5)
line = "netbox-example @ https://store.example/example.whl#sha256=" + "a" * 64
editor.set_requirement("netbox-example", line)
text = requirements.read_text(encoding="utf-8")
self.assertIn(line, text)
self.assertIn("other-package==2", text)
editor.set_requirement("netbox-example", None)
self.assertNotIn("netbox-example", requirements.read_text(encoding="utf-8"))
if __name__ == "__main__":
unittest.main()
+169
View File
@@ -0,0 +1,169 @@
import hashlib
import tempfile
import unittest
from pathlib import Path
import _bootstrap # noqa: F401
from netbox_plugin_store.client import CatalogPlugin
from netbox_plugin_store.commands import CommandResult
from netbox_plugin_store.lifecycle import LifecycleError, LifecycleRequest, LifecycleService
from netbox_plugin_store.runtime import RuntimeSettings
ARTIFACT = b"approved wheel bytes"
def catalog_plugin():
return CatalogPlugin.from_mapping(
{
"slug": "example-plugin",
"name": "Example",
"package_name": "netbox-example",
"import_name": "netbox_example",
"latest_version": "1.0.0",
"approved": True,
"releases": [
{
"version": "1.0.0",
"download_url": "https://store.example/example.whl",
"sha256": hashlib.sha256(ARTIFACT).hexdigest(),
"approved_payload_sha256": "b" * 64,
"approved": True,
"immutable": True,
"min_netbox_version": "4.6.5",
"max_netbox_version": "4.6.8",
}
],
}
)
class FakeClient:
def __init__(self, plugin):
self.plugin = plugin
self.downloads = 0
def get_plugin(self, slug):
return self.plugin
def download_artifact(self, url, destination, **kwargs):
self.downloads += 1
destination.write_bytes(ARTIFACT)
return hashlib.sha256(ARTIFACT).hexdigest(), len(ARTIFACT)
class FakeRepository:
def __init__(self):
self.audits = {}
self.statuses = []
def create_audit(self, request, actor_id):
self.audits[1] = {"status": "queued"}
return 1
def mark_running(self, audit_id):
self.audits[audit_id]["status"] = "running"
def mark_success(self, audit_id, result):
self.audits[audit_id].update(status="succeeded", result=result)
def mark_handed_off(self, audit_id, operation_id, result):
self.audits[audit_id].update(status="handed-off", operation_id=operation_id, result=result)
def mark_failed(self, audit_id, error, result):
self.audits[audit_id].update(status="failed", error=error)
def update_status(self, plugin, **values):
self.statuses.append(values)
def has_other_pending_mutation(self, slug, audit_id):
return False
class FakeRunner:
def __init__(self):
self.calls = []
def run(self, argv, **kwargs):
self.calls.append((list(argv), kwargs))
return CommandResult(0, "ok", "")
def runtime(root: Path, *, execution_mode="direct", allow=True):
config = root / "configuration.py"
config.write_text("PLUGINS = ['netbox_plugin_store']\n", encoding="utf-8")
return RuntimeSettings.from_mapping(
{
"store_url": "https://store.example",
"allowed_store_urls": ["https://store.example"],
"allowed_artifact_urls": ["https://store.example"],
"configuration_path": str(config),
"requirements_path": str(root / "local_requirements.txt"),
"manage_path": str(root / "manage.py"),
"lock_path": str(root / "operation.lock"),
"backup_dir": str(root / "backups"),
"execution_mode": execution_mode,
"allow_lifecycle_mutations": allow,
"run_migrations": False,
"collect_static": False,
"allow_package_index": False,
},
configuration_dir=root,
netbox_root=root,
base_dir=root,
netbox_version="4.6.8",
)
class LifecycleTests(unittest.TestCase):
def test_dry_run_has_no_download_or_subprocess(self):
with tempfile.TemporaryDirectory() as temp_name:
root = Path(temp_name)
client = FakeClient(catalog_plugin())
repository = FakeRepository()
runner = FakeRunner()
service = LifecycleService(runtime(root), client, repository, runner=runner, version_provider=lambda _: "")
result = service.execute(LifecycleRequest("example-plugin", "install", "1.0.0", True))
self.assertEqual(result.state, "dry-run")
self.assertEqual(client.downloads, 0)
self.assertEqual(runner.calls, [])
def test_direct_install_is_disabled_and_uses_no_index_and_pip_check(self):
with tempfile.TemporaryDirectory() as temp_name:
root = Path(temp_name)
client = FakeClient(catalog_plugin())
repository = FakeRepository()
runner = FakeRunner()
service = LifecycleService(runtime(root), client, repository, runner=runner, version_provider=lambda _: "")
result = service.execute(LifecycleRequest("example-plugin", "install", "1.0.0", False))
self.assertFalse(result.enabled)
self.assertFalse(result.restart_required)
self.assertNotIn("netbox_example", (root / "configuration.py").read_text(encoding="utf-8"))
requirement = (root / "local_requirements.txt").read_text(encoding="utf-8")
self.assertIn("#sha256=", requirement)
self.assertIn("--no-index", runner.calls[0][0])
self.assertEqual(runner.calls[1][0][-2:], ["pip", "check"])
def test_uninstall_requires_explicit_disable_first(self):
with tempfile.TemporaryDirectory() as temp_name:
root = Path(temp_name)
settings = runtime(root)
settings.configuration_path.write_text(
"PLUGINS = ['netbox_plugin_store', 'netbox_example']\n", encoding="utf-8"
)
runner = FakeRunner()
service = LifecycleService(
settings,
FakeClient(catalog_plugin()),
FakeRepository(),
runner=runner,
version_provider=lambda _: "1.0.0",
)
with self.assertRaises(LifecycleError):
service.execute(LifecycleRequest("example-plugin", "uninstall", dry_run=False))
self.assertEqual(runner.calls, [])
if __name__ == "__main__":
unittest.main()
+50
View File
@@ -0,0 +1,50 @@
import unittest
import _bootstrap # noqa: F401
from netbox_plugin_store.access import has_store_access
from netbox_plugin_store.navigation import menu
PERMISSION = "netbox_plugin_store.manage_plugin"
class NetBoxUser:
"""NetBox 4.6 users deliberately have no is_staff attribute."""
def __init__(self, *, authenticated=True, superuser=False, permissions=()):
self.is_authenticated = authenticated
self.is_superuser = superuser
self.permissions = set(permissions)
def has_perm(self, permission_name):
return permission_name in self.permissions
class AccessCompatibilityTests(unittest.TestCase):
def test_permission_user_without_is_staff_is_authorized(self):
user = NetBoxUser(permissions=(PERMISSION,))
self.assertTrue(has_store_access(user, PERMISSION))
def test_unauthenticated_and_unprivileged_users_are_rejected(self):
self.assertFalse(
has_store_access(NetBoxUser(authenticated=False, permissions=(PERMISSION,)), PERMISSION)
)
self.assertFalse(has_store_access(NetBoxUser(), PERMISSION))
def test_superuser_is_authorized_without_explicit_permission(self):
self.assertTrue(has_store_access(NetBoxUser(superuser=True), PERMISSION))
class NavigationCompatibilityTests(unittest.TestCase):
def test_menu_uses_permission_not_staff_only(self):
items = [item for _label, group_items in menu.groups for item in group_items]
self.assertEqual(len(items), 3)
for item in items:
self.assertTrue(item.auth_required)
self.assertFalse(item.staff_only)
self.assertEqual(item.permissions, [PERMISSION])
if __name__ == "__main__":
unittest.main()