feat: install plugins from approved source commits
This commit is contained in:
@@ -6,7 +6,7 @@ Dieses Repository besteht aus drei getrennten Komponenten:
|
||||
| --- | --- |
|
||||
| `store/` | Eigenständiger PHP-Store, direkt durch Apache ausgeliefert. Er synchronisiert Forgejo-/GitHub-Repositories, rendert README-Dateien und stellt den kuratierten API-Katalog bereit. |
|
||||
| `netbox_plugin/` | NetBox-Oberfläche für Katalog, Installation und Plugin-Lebenszyklus. Unterstützt NetBox 4.6.5 bis 4.6.8. |
|
||||
| `host_agent/` | Kleine privilegierte Linux-Komponente, die freigegebene Wheel-Dateien prüft und die eigentlichen Änderungen am NetBox-Host ausführt. |
|
||||
| `host_agent/` | Kleine privilegierte Linux-Komponente, die freigegebene Artefakte prüft, Source-Archive lokal als Wheel baut und die Änderungen am NetBox-Host ausführt. |
|
||||
|
||||
Der Store verwendet standardmäßig eine lokale JSON-Datei. MariaDB ist optional. Die konfigurierte Standardquelle gilt als vom Betreiber freigegeben; weitere Quellen werden im Admin-Bereich zunächst ausstehend angelegt. Automatisch erkannte Plugins und Releases erscheinen erst nach einer ausdrücklichen Freigabe im öffentlichen Katalog.
|
||||
|
||||
@@ -213,7 +213,7 @@ MariaDB erhält keinen Host-Port und ist nur im internen Compose-Netz erreichbar
|
||||
|
||||
Der voreingestellte Forgejo-Import liest Repositories von `https://git.mrblake.cc`. GitHub ist bereits als zweiter Provider implementiert und kann im Admin-Bereich über dieselbe Quellenverwaltung hinzugefügt werden.
|
||||
|
||||
Mit einem Token lassen sich auch Metadaten und README privater GitHub-Repositories synchronisieren. Deren private Release-Assets bleiben in API v1 bewusst nicht installierbar, weil der Host-Agent keine Provider-Zugangsdaten erhält; für automatische Installationen muss das Wheel als öffentliches Release-Asset oder auf einem anderen freigegebenen HTTPS-Host veröffentlicht werden.
|
||||
Mit einem Token lassen sich auch Metadaten und README privater GitHub-Repositories synchronisieren. Private Artefakte bleiben in API v1 bewusst nicht installierbar, weil der Host-Agent keine Provider-Zugangsdaten erhält. Öffentliche Projekte benötigen kein Release: Fehlt ein Wheel-Release, erzeugt der Store einen separat freizugebenden Source-Kandidaten für den exakten aktuellen Commit.
|
||||
|
||||
Der normale Ablauf ist:
|
||||
|
||||
@@ -294,7 +294,7 @@ PLUGINS_CONFIG["netbox_plugin_store"].update({
|
||||
})
|
||||
```
|
||||
|
||||
Der Agent installiert ausschließlich freigegebene, unveränderliche Wheel-Dateien. Abhängigkeiten müssen im aktuellen MVP bereits durch den Betreiber bereitgestellt sein. Vollständige Sicherheits- und Recovery-Hinweise stehen in [`host_agent/README.md`](host_agent/README.md).
|
||||
Der Agent installiert ausschließlich freigegebene, unveränderliche Artefakte. Source-Archive werden commitgebunden geprüft, offline lokal in ein Wheel gebaut und als gehashte Datei unter `/opt/netbox/.netbox-store-wheels` persistiert. Abhängigkeiten und Build-Backends müssen bereits durch den Betreiber bereitgestellt sein. Vollständige Sicherheits- und Recovery-Hinweise stehen in [`host_agent/README.md`](host_agent/README.md).
|
||||
|
||||
## API v1
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ decision is revalidated against the configured JSON API immediately before a lif
|
||||
|
||||
The daemon is **dry-run by default**. It serializes operations, persists idempotency and state in
|
||||
SQLite, accepts one bounded JSON line per Unix-stream connection, verifies Linux peer credentials,
|
||||
downloads only an approved immutable wheel, and invokes subprocesses only as fixed argument arrays
|
||||
downloads only an approved immutable wheel or commit-bound source archive, and invokes subprocesses only as fixed argument arrays
|
||||
with `shell=False`.
|
||||
|
||||
## Install and one-time operator setup
|
||||
@@ -27,7 +27,7 @@ active; this switch only permits private/reserved DNS results.
|
||||
The agent owns only these two configured files:
|
||||
|
||||
- `paths.include_path`, which contains only `STORE_PLUGINS = [...]`;
|
||||
- `paths.requirements_path`, which contains the locked direct wheel references.
|
||||
- `paths.requirements_path`, which contains locked wheel references. Locally built wheels are cached below `/opt/netbox/.netbox-store-wheels`.
|
||||
|
||||
In the operator-owned NetBox `configuration.py`, add once, after the normal `PLUGINS` declaration:
|
||||
|
||||
@@ -112,7 +112,7 @@ value as an opaque Store marker, and the agent compares it exactly with the fres
|
||||
Artifact `sha256`, in contrast, is always a lowercase 64-character SHA-256.
|
||||
|
||||
The plugin and release must be approved, the release immutable and compatible with the configured
|
||||
NetBox version. The artifact must be a valid wheel whose filename distribution and version match
|
||||
NetBox version. A wheel's filename distribution and version must match
|
||||
the catalog, and its byte count, digest, host, scheme and DNS addresses are checked while streaming.
|
||||
Redirects, source distributions, private/reserved DNS targets (unless explicitly enabled for a test
|
||||
environment), and catalog additions outside the v1 schema fail closed.
|
||||
@@ -151,6 +151,7 @@ netbox-store-agent status 20f4274f-d4e5-42bf-9164-967b1a774481
|
||||
|
||||
- `install` and `update` re-fetch plugin and release, exactly match the approval token, download and
|
||||
validate the wheel, then use `pip --no-index --no-deps --only-binary=:all: --require-hashes`.
|
||||
- Source archives must be tied to an exact commit. The agent rejects unsafe archive members and requires configured `unshare`/`setpriv` executables plus a dedicated build UID/GID. It builds without network access and with dropped privileges via `pip wheel --no-index --no-deps --no-build-isolation`, validates the result, and caches only that wheel for future NetBox upgrades. Reviewing source code remains a privileged trust decision because Python build backends execute code.
|
||||
- New installs remain disabled. Updating an enabled plugin runs NetBox `migrate`, `collectstatic`,
|
||||
and restarts every configured service (`netbox` and `netbox-rq` in the example).
|
||||
- `enable` revalidates its installed release, writes the include, migrates, collects static files,
|
||||
|
||||
@@ -17,7 +17,7 @@ connection_timeout_seconds = 5
|
||||
worker_threads = 1
|
||||
|
||||
[store]
|
||||
base_url = "https://store.example.invalid"
|
||||
base_url = "https://netbox.mrblake.cc"
|
||||
plugin_endpoint_template = "/api/v1/plugins/{plugin_slug}/"
|
||||
release_endpoint_template = "/api/v1/plugins/{plugin_slug}/releases/{version}/"
|
||||
timeout_seconds = 10
|
||||
@@ -26,7 +26,7 @@ max_artifact_bytes = 268435456
|
||||
allow_private_addresses = false
|
||||
allow_http_for_testing = false
|
||||
# Add an artifact origin only when releases intentionally use that origin.
|
||||
allowed_hosts = ["store.example.invalid"]
|
||||
allowed_hosts = ["netbox.mrblake.cc", "git.mrblake.cc", "github.com", "codeload.github.com"]
|
||||
# bearer_token_file = "/etc/netbox-store-agent/store.token"
|
||||
# ca_file = "/etc/ssl/certs/internal-store-ca.pem"
|
||||
|
||||
@@ -42,6 +42,11 @@ manage_path = "/opt/netbox/netbox/manage.py"
|
||||
systemctl_path = "/usr/bin/systemctl"
|
||||
services = ["netbox", "netbox-rq"]
|
||||
command_timeout_seconds = 900
|
||||
unshare_path = "/usr/bin/unshare"
|
||||
setpriv_path = "/usr/bin/setpriv"
|
||||
# Dedicated unprivileged account used only for reviewed source builds.
|
||||
source_build_uid = 65534
|
||||
source_build_gid = 65534
|
||||
|
||||
[policy]
|
||||
netbox_version = "4.6.8"
|
||||
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
import socket
|
||||
import ssl
|
||||
import stat
|
||||
import tarfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -264,6 +265,8 @@ class ReleasePlan:
|
||||
artifact_sha256: str
|
||||
artifact_size: int
|
||||
approved_payload_sha256: str
|
||||
artifact_kind: str = "wheel"
|
||||
artifact_filename: str = ""
|
||||
|
||||
def requirement(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -275,6 +278,14 @@ class ReleasePlan:
|
||||
"size": self.artifact_size,
|
||||
}
|
||||
|
||||
def artifact_lock(self) -> dict[str, Any]:
|
||||
return {
|
||||
**self.requirement(),
|
||||
"artifact_kind": self.artifact_kind,
|
||||
"artifact_filename": self.artifact_filename or self.filename,
|
||||
"approved_payload_sha256": self.approved_payload_sha256,
|
||||
}
|
||||
|
||||
|
||||
def _object(value: Any, context: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||
@@ -321,6 +332,8 @@ class StoreClient:
|
||||
"download_url",
|
||||
"sha256",
|
||||
"artifact_size",
|
||||
"artifact_kind",
|
||||
"artifact_filename",
|
||||
"commit_sha",
|
||||
"min_netbox_version",
|
||||
"max_netbox_version",
|
||||
@@ -432,12 +445,26 @@ class StoreClient:
|
||||
raise CatalogError("release.commit_sha is invalid")
|
||||
if data["published_at"] is not None:
|
||||
_bounded_string(data["published_at"], "release.published_at", 100)
|
||||
parsed_url = urlsplit(download_url)
|
||||
filename = Path(parsed_url.path).name
|
||||
if not filename or not filename.endswith(".whl") or len(filename) > 255:
|
||||
raise CatalogError("approved artifact must be a wheel with a safe filename")
|
||||
artifact_kind = _bounded_string(data["artifact_kind"], "release.artifact_kind", 32)
|
||||
if artifact_kind not in {"wheel", "source_archive"}:
|
||||
raise CatalogError("release.artifact_kind is unsupported")
|
||||
filename = _bounded_string(data["artifact_filename"], "release.artifact_filename", 255)
|
||||
if Path(filename).name != filename:
|
||||
raise CatalogError("release.artifact_filename is unsafe")
|
||||
if artifact_kind == "wheel" and not filename.endswith(".whl"):
|
||||
raise CatalogError("wheel artifact filename is invalid")
|
||||
if artifact_kind == "source_archive":
|
||||
if (
|
||||
not filename.endswith(".tar.gz")
|
||||
or len(commit) != 40
|
||||
or commit not in urlsplit(download_url).path
|
||||
):
|
||||
raise CatalogError("source archive must be bound to an exact commit")
|
||||
# URL host/scheme/DNS are revalidated by the transport at download time.
|
||||
return ReleasePlan(plugin, version_text, download_url, filename, sha256, size, token)
|
||||
return ReleasePlan(
|
||||
plugin, version_text, download_url, filename, sha256, size, token,
|
||||
artifact_kind, filename,
|
||||
)
|
||||
|
||||
def get_release(self, slug: str, version: str) -> ReleasePlan:
|
||||
plugin = self.get_plugin(slug)
|
||||
@@ -464,8 +491,15 @@ class StoreClient:
|
||||
expected_size=plan.artifact_size,
|
||||
expected_sha256=plan.artifact_sha256,
|
||||
)
|
||||
if plan.artifact_kind == "source_archive":
|
||||
self._validate_source_archive(destination)
|
||||
return destination
|
||||
self.validate_wheel(plan, destination)
|
||||
return destination
|
||||
|
||||
def validate_wheel(self, plan: ReleasePlan, destination: Path) -> None:
|
||||
try:
|
||||
distribution, wheel_version, _build, _tags = parse_wheel_filename(plan.filename)
|
||||
distribution, wheel_version, _build, _tags = parse_wheel_filename(destination.name)
|
||||
except (InvalidVersion, ValueError) as exc:
|
||||
raise CatalogError("artifact filename is not a valid wheel filename") from exc
|
||||
if canonicalize_name(distribution) != canonicalize_name(plan.plugin.package_name):
|
||||
@@ -494,4 +528,36 @@ class StoreClient:
|
||||
raise CatalogError("wheel archive failed its integrity check")
|
||||
except (OSError, zipfile.BadZipFile) as exc:
|
||||
raise CatalogError("artifact is not a valid wheel archive") from exc
|
||||
return destination
|
||||
|
||||
def _validate_source_archive(self, archive_path: Path) -> None:
|
||||
try:
|
||||
with tarfile.open(archive_path, "r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
if not members or len(members) > 10_000:
|
||||
raise CatalogError("source archive has an invalid member count")
|
||||
expanded = 0
|
||||
roots: set[str] = set()
|
||||
has_build_file = False
|
||||
for member in members:
|
||||
parts = Path(member.name).parts
|
||||
if (
|
||||
not parts
|
||||
or member.name.startswith(("/", "\\"))
|
||||
or ".." in parts
|
||||
or "\x00" in member.name
|
||||
):
|
||||
raise CatalogError("source archive contains an unsafe member")
|
||||
if member.issym() or member.islnk() or member.isdev() or member.isfifo():
|
||||
raise CatalogError("source archive contains a forbidden member type")
|
||||
roots.add(parts[0])
|
||||
expanded += member.size
|
||||
if expanded > min(
|
||||
self.config.store.max_artifact_bytes * 20, 2 * 1024**3
|
||||
):
|
||||
raise CatalogError("source archive expands beyond the safety limit")
|
||||
if len(parts) == 2 and parts[1] in {"pyproject.toml", "setup.py"}:
|
||||
has_build_file = True
|
||||
if len(roots) != 1 or not has_build_file:
|
||||
raise CatalogError("source archive must have one root and a Python build file")
|
||||
except (OSError, tarfile.TarError) as exc:
|
||||
raise CatalogError("artifact is not a valid source archive") from exc
|
||||
|
||||
@@ -71,6 +71,10 @@ class CommandSettings:
|
||||
systemctl_path: Path
|
||||
services: tuple[str, ...]
|
||||
command_timeout_seconds: int
|
||||
unshare_path: Path | None = None
|
||||
setpriv_path: Path | None = None
|
||||
source_build_uid: int = 65534
|
||||
source_build_gid: int = 65534
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -240,7 +244,11 @@ def load_config(path: str | Path, *, allow_insecure_owner: bool = False) -> Conf
|
||||
commands_data = _table(
|
||||
root,
|
||||
"commands",
|
||||
{"python_path", "manage_path", "systemctl_path", "services", "command_timeout_seconds"},
|
||||
{
|
||||
"python_path", "manage_path", "systemctl_path", "services",
|
||||
"command_timeout_seconds", "unshare_path", "setpriv_path",
|
||||
"source_build_uid", "source_build_gid",
|
||||
},
|
||||
)
|
||||
policy_data = _table(
|
||||
root,
|
||||
@@ -342,6 +350,10 @@ def load_config(path: str | Path, *, allow_insecure_owner: bool = False) -> Conf
|
||||
systemctl_path=require_absolute_path(_string(commands_data, "systemctl_path"), "systemctl_path"),
|
||||
services=services,
|
||||
command_timeout_seconds=_int(commands_data, "command_timeout_seconds", 900, 1, 7200),
|
||||
unshare_path=_optional_path(commands_data, "unshare_path"),
|
||||
setpriv_path=_optional_path(commands_data, "setpriv_path"),
|
||||
source_build_uid=_int(commands_data, "source_build_uid", 65534, 1, 2**31 - 1),
|
||||
source_build_gid=_int(commands_data, "source_build_gid", 65534, 1, 2**31 - 1),
|
||||
)
|
||||
|
||||
versions: dict[str, str] = {}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
@@ -56,7 +60,11 @@ class OperationProcessor:
|
||||
|
||||
@staticmethod
|
||||
def _same_release(existing: ManagedPlugin, plan: ReleasePlan) -> None:
|
||||
if existing.version != plan.version or existing.requirements != (plan.requirement(),):
|
||||
expected = plan.artifact_lock() if existing.artifact_lock else plan.requirement()
|
||||
current_lock = existing.artifact_lock or (
|
||||
existing.requirements[0] if existing.requirements else {}
|
||||
)
|
||||
if existing.version != plan.version or current_lock != expected:
|
||||
raise PolicyError(
|
||||
"managed artifact lock differs from the current immutable Store release"
|
||||
)
|
||||
@@ -105,6 +113,71 @@ class OperationProcessor:
|
||||
],
|
||||
)
|
||||
|
||||
def _build_source_wheel(
|
||||
self, operation_id: str, directory: Path, plan: ReleasePlan, archive: Path
|
||||
) -> Path:
|
||||
if (
|
||||
os.name != "posix"
|
||||
or self.config.commands.unshare_path is None
|
||||
or self.config.commands.setpriv_path is None
|
||||
):
|
||||
raise PolicyError("source builds require configured Linux unshare and setpriv executables")
|
||||
source_dir = directory / "source"
|
||||
wheel_dir = directory / "wheel"
|
||||
source_dir.mkdir(mode=0o700)
|
||||
wheel_dir.mkdir(mode=0o700)
|
||||
with tarfile.open(archive, "r:gz") as bundle:
|
||||
bundle.extractall(source_dir, filter="data")
|
||||
roots = list(source_dir.iterdir())
|
||||
if len(roots) != 1 or not roots[0].is_dir():
|
||||
raise PolicyError("source archive extraction produced an invalid root")
|
||||
uid = self.config.commands.source_build_uid
|
||||
gid = self.config.commands.source_build_gid
|
||||
for path in [directory, source_dir, wheel_dir, *source_dir.rglob("*")]:
|
||||
os.chown(path, uid, gid, follow_symlinks=False)
|
||||
if path.is_dir():
|
||||
os.chmod(path, 0o700)
|
||||
self._run(
|
||||
operation_id,
|
||||
"source_build",
|
||||
[
|
||||
str(self.config.commands.unshare_path), "--net", "--",
|
||||
str(self.config.commands.setpriv_path), f"--reuid={uid}", f"--regid={gid}",
|
||||
"--clear-groups", "--no-new-privs", "--",
|
||||
str(self.config.commands.python_path), "-m", "pip", "wheel",
|
||||
"--no-input", "--disable-pip-version-check", "--no-index", "--no-deps",
|
||||
"--no-build-isolation", "--wheel-dir", str(wheel_dir), str(roots[0]),
|
||||
],
|
||||
)
|
||||
wheels = list(wheel_dir.glob("*.whl"))
|
||||
if len(wheels) != 1:
|
||||
raise PolicyError("source build must produce exactly one wheel")
|
||||
self.store.validate_wheel(plan, wheels[0])
|
||||
return wheels[0]
|
||||
|
||||
def _cache_wheel(self, plan: ReleasePlan, wheel: Path) -> tuple[Path, dict[str, object]]:
|
||||
digest = hashlib.sha256(wheel.read_bytes()).hexdigest()
|
||||
cache = (
|
||||
self.config.paths.allowed_root
|
||||
/ ".netbox-store-wheels"
|
||||
/ plan.plugin.slug
|
||||
/ plan.approved_payload_sha256
|
||||
)
|
||||
cache.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
destination = cache / wheel.name
|
||||
if destination.exists() and destination.is_symlink():
|
||||
raise PolicyError("wheel cache destination may not be a symlink")
|
||||
temporary = cache / (wheel.name + ".tmp")
|
||||
shutil.copyfile(wheel, temporary)
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, destination)
|
||||
requirement = {
|
||||
"package_name": plan.plugin.package_name, "version": plan.version,
|
||||
"download_url": destination.as_uri(), "filename": destination.name,
|
||||
"sha256": digest, "size": destination.stat().st_size,
|
||||
}
|
||||
return destination, requirement
|
||||
|
||||
def _pip_uninstall(self, operation_id: str, package_name: str) -> None:
|
||||
self._run(
|
||||
operation_id,
|
||||
@@ -184,23 +257,26 @@ class OperationProcessor:
|
||||
if existing is not None:
|
||||
self._same_identity(existing, plan.plugin)
|
||||
enabled = existing.enabled if existing else False
|
||||
managed = ManagedPlugin(
|
||||
slug=request.plugin_slug,
|
||||
package_name=plan.plugin.package_name,
|
||||
import_name=plan.plugin.import_name,
|
||||
version=plan.version,
|
||||
enabled=enabled,
|
||||
requirements=(plan.requirement(),),
|
||||
)
|
||||
temp_root = self.config.paths.temp_dir
|
||||
if temp_root.is_symlink():
|
||||
raise PolicyError("temporary directory may not be a symlink")
|
||||
temp_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
with tempfile.TemporaryDirectory(prefix="operation-", dir=temp_root) as temporary:
|
||||
self._event(operation_id, "artifact", "Downloading and verifying approved wheel")
|
||||
wheel = self.store.download_release(plan, Path(temporary))
|
||||
self._event(operation_id, "artifact", "Downloading and verifying approved artifact")
|
||||
artifact = self.store.download_release(plan, Path(temporary))
|
||||
if self.config.agent.dry_run:
|
||||
return self._dry_result(request, version=plan.version, artifact_verified=True), False
|
||||
wheel = (
|
||||
self._build_source_wheel(operation_id, Path(temporary), plan, artifact)
|
||||
if plan.artifact_kind == "source_archive"
|
||||
else artifact
|
||||
)
|
||||
wheel, requirement = self._cache_wheel(plan, wheel)
|
||||
managed = ManagedPlugin(
|
||||
slug=request.plugin_slug, package_name=plan.plugin.package_name,
|
||||
import_name=plan.plugin.import_name, version=plan.version, enabled=enabled,
|
||||
requirements=(requirement,), artifact_lock=plan.artifact_lock(),
|
||||
)
|
||||
self._mark_mutated(operation_id)
|
||||
host_mutated = True
|
||||
self._pip_install(operation_id, Path(temporary), managed, wheel)
|
||||
@@ -239,6 +315,7 @@ class OperationProcessor:
|
||||
existing.version,
|
||||
True,
|
||||
existing.requirements,
|
||||
existing.artifact_lock,
|
||||
)
|
||||
if self.config.agent.dry_run:
|
||||
return self._dry_result(request, version=existing.version, enabled=True), False
|
||||
@@ -260,6 +337,7 @@ class OperationProcessor:
|
||||
existing.version,
|
||||
False,
|
||||
existing.requirements,
|
||||
existing.artifact_lock,
|
||||
)
|
||||
if self.config.agent.dry_run:
|
||||
return self._dry_result(request, version=existing.version, enabled=False), False
|
||||
|
||||
@@ -31,6 +31,7 @@ class ManagedPlugin:
|
||||
version: str
|
||||
enabled: bool
|
||||
requirements: tuple[dict[str, Any], ...]
|
||||
artifact_lock: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class Journal:
|
||||
@@ -111,10 +112,17 @@ class Journal:
|
||||
version TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL CHECK(enabled IN (0, 1)),
|
||||
requirements_json TEXT NOT NULL,
|
||||
artifact_lock_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(managed_plugins)")}
|
||||
if "artifact_lock_json" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE managed_plugins ADD COLUMN artifact_lock_json "
|
||||
"TEXT NOT NULL DEFAULT '{}'"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _operation_dict(row: sqlite3.Row, events: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
@@ -306,6 +314,7 @@ class Journal:
|
||||
version=row["version"],
|
||||
enabled=bool(row["enabled"]),
|
||||
requirements=tuple(requirements),
|
||||
artifact_lock=json.loads(row["artifact_lock_json"]),
|
||||
)
|
||||
|
||||
def get_managed_plugin(self, slug: str) -> ManagedPlugin | None:
|
||||
@@ -322,18 +331,20 @@ class Journal:
|
||||
|
||||
def upsert_managed_plugin(self, plugin: ManagedPlugin) -> None:
|
||||
requirements_json = canonical_json(list(plugin.requirements))
|
||||
artifact_lock_json = canonical_json(plugin.artifact_lock)
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO managed_plugins(
|
||||
slug, package_name, import_name, version, enabled, requirements_json, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
slug, package_name, import_name, version, enabled, requirements_json, artifact_lock_json, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(slug) DO UPDATE SET
|
||||
package_name=excluded.package_name,
|
||||
import_name=excluded.import_name,
|
||||
version=excluded.version,
|
||||
enabled=excluded.enabled,
|
||||
requirements_json=excluded.requirements_json,
|
||||
artifact_lock_json=excluded.artifact_lock_json,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
@@ -343,6 +354,7 @@ class Journal:
|
||||
plugin.version,
|
||||
int(plugin.enabled),
|
||||
requirements_json,
|
||||
artifact_lock_json,
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -144,6 +144,7 @@ class ManagedFiles:
|
||||
def render_requirements(self, plugins: Iterable[ManagedPlugin]) -> bytes:
|
||||
requirements: dict[str, dict[str, object]] = {}
|
||||
schemes = {"https", "http"} if self.config.store.allow_http_for_testing else {"https"}
|
||||
schemes.add("file")
|
||||
for plugin in plugins:
|
||||
for raw in plugin.requirements:
|
||||
if not isinstance(raw, dict):
|
||||
@@ -166,10 +167,17 @@ class ManagedFiles:
|
||||
if not isinstance(url, str) or any(char in url for char in "\r\n\t "):
|
||||
raise ValidationError("managed requirement URL is invalid")
|
||||
parsed = urlsplit(url)
|
||||
remote_invalid = parsed.scheme != "file" and (
|
||||
not parsed.hostname or parsed.hostname.lower() not in self.config.store.allowed_hosts
|
||||
)
|
||||
local_path = Path(parsed.path.lstrip("/") if os.name == "nt" else parsed.path)
|
||||
local_invalid = parsed.scheme == "file" and not is_relative_to(
|
||||
local_path.resolve(strict=False), self.config.paths.allowed_root
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in schemes
|
||||
or not parsed.hostname
|
||||
or parsed.hostname.lower() not in self.config.store.allowed_hosts
|
||||
or remote_invalid
|
||||
or local_invalid
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.fragment
|
||||
|
||||
@@ -88,6 +88,8 @@ def release_json(version: str = "1.2.3", **overrides: Any) -> dict[str, Any]:
|
||||
"download_url": f"http://artifacts.test/demo_plugin-{version}-py3-none-any.whl",
|
||||
"sha256": "a" * 64,
|
||||
"artifact_size": 100,
|
||||
"artifact_kind": "wheel",
|
||||
"artifact_filename": f"demo_plugin-{version}-py3-none-any.whl",
|
||||
"commit_sha": "",
|
||||
"min_netbox_version": "4.6.5",
|
||||
"max_netbox_version": "4.6.8",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -48,6 +50,36 @@ class CatalogTests(unittest.TestCase):
|
||||
plan = StoreClient(self.config, transport).get_release("demo-plugin", "1.2.3")
|
||||
self.assertEqual(plan.version, "1.2.3")
|
||||
|
||||
def test_commit_bound_source_archive_is_verified(self) -> None:
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
|
||||
content = b"[build-system]\nrequires = []\n"
|
||||
info = tarfile.TarInfo("demo/pyproject.toml")
|
||||
info.size = len(content)
|
||||
archive.addfile(info, io.BytesIO(content))
|
||||
artifact = buffer.getvalue()
|
||||
commit = "d" * 40
|
||||
release = release_json(
|
||||
download_url=f"http://artifacts.test/demo/archive/{commit}.tar.gz",
|
||||
artifact_kind="source_archive",
|
||||
artifact_filename="demo-plugin-1.2.3-source.tar.gz",
|
||||
commit_sha=commit,
|
||||
sha256=hashlib.sha256(artifact).hexdigest(),
|
||||
artifact_size=len(artifact),
|
||||
)
|
||||
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,
|
||||
},
|
||||
artifact,
|
||||
)
|
||||
client = StoreClient(self.config, transport)
|
||||
plan = client.get_release("demo-plugin", "1.2.3")
|
||||
directory = self.root / "source-operation"
|
||||
directory.mkdir()
|
||||
self.assertEqual(client.download_release(plan, directory).read_bytes(), artifact)
|
||||
|
||||
def test_unapproved_or_mutable_release_rejected(self) -> None:
|
||||
for change in ({"approved": False}, {"immutable": False}, {"status": "pending"}):
|
||||
with self.subTest(change=change):
|
||||
|
||||
@@ -77,6 +77,8 @@ class Release:
|
||||
approved: bool
|
||||
immutable: bool
|
||||
approved_payload_sha256: str
|
||||
artifact_kind: str = "wheel"
|
||||
artifact_filename: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: dict[str, Any]) -> "Release":
|
||||
@@ -104,6 +106,8 @@ class Release:
|
||||
approved=value.get("approved") is True or value.get("status") == "approved",
|
||||
immutable=value.get("immutable") is True,
|
||||
approved_payload_sha256=approved_payload_sha256,
|
||||
artifact_kind=str(value.get("artifact_kind") or "wheel").strip(),
|
||||
artifact_filename=str(value.get("artifact_filename") or "").strip(),
|
||||
)
|
||||
|
||||
def supports(self, netbox_version: str, plugin: "CatalogPlugin") -> bool:
|
||||
|
||||
@@ -235,6 +235,8 @@ class LifecycleService:
|
||||
raise LifecycleError("Approved release has no artifact URL.")
|
||||
if not release.sha256:
|
||||
raise LifecycleError("Approved release has no artifact SHA-256. Commit hashes are not accepted.")
|
||||
if release.artifact_kind not in {"wheel", "source_archive"}:
|
||||
raise LifecycleError("Approved release has an unsupported artifact type.")
|
||||
|
||||
@staticmethod
|
||||
def _assert_state(action: str, installed: str, enabled: bool, runtime_active: bool) -> None:
|
||||
@@ -287,6 +289,8 @@ class LifecycleService:
|
||||
return suffix if re.fullmatch(r"\.[A-Za-z0-9.]{1,16}", suffix) else ".artifact"
|
||||
|
||||
def _download(self, release: Release, directory: Path) -> Path:
|
||||
if release.artifact_kind == "source_archive":
|
||||
raise LifecycleError("Source archives require execution_mode='agent' for a verified local wheel build.")
|
||||
artifact = directory / f"artifact{self._artifact_suffix(release)}"
|
||||
self.client.download_artifact(
|
||||
release.download_url,
|
||||
|
||||
@@ -232,6 +232,18 @@ input:focus, select:focus, textarea:focus { border-color: var(--blue); box-shado
|
||||
.sync-errors ul { margin: 7px 0 0; padding-left: 18px; }
|
||||
.sync-errors li { display: list-item; padding: 3px 0; border: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.install-hero { padding: 64px 0 48px; color: #fff; background: #102a43; }
|
||||
.install-hero h1 { margin: 6px 0 12px; font-size: clamp(2rem, 5vw, 3.5rem); letter-spacing: -.04em; }
|
||||
.install-hero p:last-child { color: #c6d8e5; }
|
||||
.prose-shell { max-width: 880px; }
|
||||
.install-guide { padding-block: 42px 80px; }
|
||||
.install-guide h2 { margin: 38px 0 10px; }
|
||||
.install-guide p { color: var(--ink-soft); line-height: 1.7; }
|
||||
.install-guide pre { padding: 19px; overflow-x: auto; border: 1px solid #214761; border-radius: 11px; color: #eaf4fb; background: #102a43; line-height: 1.55; }
|
||||
.install-guide code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.notice { padding: 16px 18px; border-left: 4px solid var(--blue); border-radius: 8px; background: #edf5ff; line-height: 1.55; }
|
||||
.notice.warning { margin-top: 28px; border-left-color: var(--amber); background: var(--amber-bg); }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.hero-grid { grid-template-columns: 1fr; gap: 35px; }
|
||||
.hero-stat { width: min(350px, 100%); }
|
||||
|
||||
@@ -17,6 +17,7 @@ final class Approval
|
||||
'commitSha' => strtolower((string) ($release['commitSha'] ?? '')),
|
||||
'downloadUrl' => (string) ($release['downloadUrl'] ?? ''),
|
||||
'artifactKind' => (string) ($release['artifactKind'] ?? ''),
|
||||
'artifactFilename' => (string) ($release['artifactFilename'] ?? ''),
|
||||
'importName' => (string) ($plugin['importName'] ?? ''),
|
||||
'maxNetboxVersion' => (string) (($release['maxNetboxVersion'] ?? '') ?: ($plugin['maxNetboxVersion'] ?? '')),
|
||||
'minNetboxVersion' => (string) (($release['minNetboxVersion'] ?? '') ?: ($plugin['minNetboxVersion'] ?? '')),
|
||||
@@ -95,7 +96,10 @@ final class Approval
|
||||
if (!is_array($url) || ($url['scheme'] ?? '') !== 'https' || empty($url['host']) || isset($url['user']) || isset($url['pass'])) {
|
||||
$errors[] = 'Download-URL muss credential-freies HTTPS sein.';
|
||||
} else {
|
||||
$errors = array_merge($errors, self::wheelErrors($plugin, $release, (string) ($url['path'] ?? '')));
|
||||
$kind = (string) ($release['artifactKind'] ?? '');
|
||||
$errors = array_merge($errors, $kind === 'source_archive'
|
||||
? self::sourceErrors($release, (string) ($url['path'] ?? ''))
|
||||
: self::wheelErrors($plugin, $release, (string) ($url['path'] ?? '')));
|
||||
}
|
||||
$commit = (string) ($release['commitSha'] ?? '');
|
||||
if ($commit !== '' && preg_match('/^[a-f0-9]{40}$/', $commit) !== 1) {
|
||||
@@ -109,8 +113,8 @@ final class Approval
|
||||
if (!self::immutable($release)) {
|
||||
$errors[] = 'Artefakt-SHA oder Größe fehlt.';
|
||||
}
|
||||
if (($release['artifactKind'] ?? '') !== 'wheel') {
|
||||
$errors[] = 'Im API-v1-Katalog sind ausschließlich Wheel-Artefakte freigabefähig.';
|
||||
if (!in_array(($release['artifactKind'] ?? ''), ['wheel', 'source_archive'], true)) {
|
||||
$errors[] = 'Nur Wheel- oder commitgebundene Source-Artefakte sind freigabefähig.';
|
||||
}
|
||||
if (!empty($release['draft']) || !empty($release['withdrawn'])) {
|
||||
$errors[] = 'Drafts oder zurückgezogene Releases sind nicht freigabefähig.';
|
||||
@@ -259,4 +263,22 @@ final class Approval
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $release @return list<string> */
|
||||
private static function sourceErrors(array $release, string $path): array
|
||||
{
|
||||
$commit = (string) ($release['commitSha'] ?? '');
|
||||
$filename = (string) ($release['artifactFilename'] ?? '');
|
||||
$errors = [];
|
||||
if (preg_match('/^[a-f0-9]{40}$/', $commit) !== 1) {
|
||||
$errors[] = 'Source-Artefakte benoetigen einen exakten 40-stelligen Commit-SHA.';
|
||||
}
|
||||
if ($filename === '' || strlen($filename) > 255 || preg_match('/^[A-Za-z0-9._+-]+\.tar\.gz$/', $filename) !== 1) {
|
||||
$errors[] = 'Source-Artefaktname ist ungueltig.';
|
||||
}
|
||||
if ($commit !== '' && !str_contains(rawurldecode($path), $commit)) {
|
||||
$errors[] = 'Source-Download-URL ist nicht an den freigegebenen Commit gebunden.';
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,8 @@ final class Catalog
|
||||
'download_url' => (string) $release['downloadUrl'],
|
||||
'sha256' => strtolower((string) $release['sha256']),
|
||||
'artifact_size' => (int) $release['artifactSize'],
|
||||
'artifact_kind' => (string) $release['artifactKind'],
|
||||
'artifact_filename' => (string) $release['artifactFilename'],
|
||||
'commit_sha' => (string) ($release['commitSha'] ?? ''),
|
||||
'min_netbox_version' => (string) (($release['minNetboxVersion'] ?? '') ?: ($plugin['minNetboxVersion'] ?? '')),
|
||||
'max_netbox_version' => (string) (($release['maxNetboxVersion'] ?? '') ?: ($plugin['maxNetboxVersion'] ?? '')),
|
||||
|
||||
@@ -60,6 +60,9 @@ final class Application
|
||||
if ($request->method === 'GET' && $request->path === '/') {
|
||||
return $this->home($request);
|
||||
}
|
||||
if ($request->method === 'GET' && $request->path === '/installation') {
|
||||
return $this->viewResponse('installation', ['title' => 'NetBox Store installieren'], 200, $request);
|
||||
}
|
||||
if ($request->method === 'GET' && preg_match('#^/plugins/([^/]+)$#', $request->path, $match)) {
|
||||
return $this->plugin($request, $match[1]);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ final class ForgejoAdapter extends AbstractAdapter
|
||||
'archived' => (bool) ($item['archived'] ?? false),
|
||||
'fork' => (bool) ($item['fork'] ?? false),
|
||||
'empty' => (bool) ($item['empty'] ?? false),
|
||||
'private' => (bool) ($item['private'] ?? false),
|
||||
];
|
||||
$this->validateRepository($repository);
|
||||
return $repository;
|
||||
@@ -135,6 +136,16 @@ final class ForgejoAdapter extends AbstractAdapter
|
||||
return $releases;
|
||||
}
|
||||
|
||||
public function sourceArchiveUrl(array $repository, string $commitSha): ?string
|
||||
{
|
||||
if (!empty($repository['private'])) {
|
||||
return null;
|
||||
}
|
||||
$this->validateRepository($repository);
|
||||
$this->assertCommit($commitSha);
|
||||
return rtrim((string) $repository['htmlUrl'], '/') . '/archive/' . $commitSha . '.tar.gz';
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>>|null */
|
||||
private function pagedRepositories(string $endpoint): ?array
|
||||
{
|
||||
|
||||
@@ -138,6 +138,16 @@ final class GitHubAdapter extends AbstractAdapter
|
||||
return $releases;
|
||||
}
|
||||
|
||||
public function sourceArchiveUrl(array $repository, string $commitSha): ?string
|
||||
{
|
||||
if (!empty($repository['private'])) {
|
||||
return null;
|
||||
}
|
||||
$this->validateRepository($repository);
|
||||
$this->assertCommit($commitSha);
|
||||
return 'https://codeload.github.com/' . $repository['fullName'] . '/tar.gz/' . $commitSha;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>>|null */
|
||||
private function paged(string $endpoint): ?array
|
||||
{
|
||||
|
||||
@@ -24,6 +24,9 @@ interface SourceAdapter
|
||||
/** @param array<string,mixed> $repository @return list<array<string,mixed>> */
|
||||
public function listReleases(array $repository): array;
|
||||
|
||||
/** @param array<string,mixed> $repository */
|
||||
public function sourceArchiveUrl(array $repository, string $commitSha): ?string;
|
||||
|
||||
/** @return array{sha256:string,artifactSize:int} */
|
||||
public function hashArtifact(string $url, string $expectedSha256 = ''): array;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ use Closure;
|
||||
final class SyncService
|
||||
{
|
||||
private const PLUGIN_SECURITY = ['packageName', 'importName', 'minNetboxVersion', 'maxNetboxVersion'];
|
||||
private const RELEASE_SECURITY = ['version', 'downloadUrl', 'sha256', 'artifactSize', 'artifactKind', 'commitSha', 'minNetboxVersion', 'maxNetboxVersion', 'withdrawn'];
|
||||
private const RELEASE_SECURITY = ['version', 'downloadUrl', 'sha256', 'artifactSize', 'artifactKind', 'artifactFilename', 'commitSha', 'minNetboxVersion', 'maxNetboxVersion', 'withdrawn'];
|
||||
|
||||
public function __construct(
|
||||
private readonly StoreRepository $repository,
|
||||
@@ -199,7 +199,7 @@ final class SyncService
|
||||
// GitHub's browser_download_url is deliberately public-only.
|
||||
// Provider credentials stay on the API origin and the v1
|
||||
// Host-Agent has no credentials for private asset downloads.
|
||||
if (($source['provider'] ?? '') === 'github' && !empty($repository['private'])) {
|
||||
if (!empty($repository['private'])) {
|
||||
$this->withdrawMissingReleases((string) $plugin['id'], []);
|
||||
$run['errors'][] = [
|
||||
'repository' => $repository['fullName'],
|
||||
@@ -209,6 +209,29 @@ final class SyncService
|
||||
}
|
||||
|
||||
$releaseInfos = $adapter->listReleases($result['repository']);
|
||||
if ($releaseInfos === []) {
|
||||
$sourceUrl = $adapter->sourceArchiveUrl($result['repository'], (string) $result['repository']['commitSha']);
|
||||
$version = Support::safeVersion($plugin['latestVersion'] ?? '');
|
||||
$commit = (string) $result['repository']['commitSha'];
|
||||
if ($sourceUrl !== null && $version !== '' && preg_match('/^[a-f0-9]{40}$/', $commit)) {
|
||||
$package = strtolower((string) preg_replace('/[-_.]+/', '-', (string) $plugin['packageName']));
|
||||
$releaseInfos[] = [
|
||||
'externalId' => 'source:' . $commit,
|
||||
'version' => $version,
|
||||
'title' => 'Source ' . substr($commit, 0, 12),
|
||||
'releaseUrl' => rtrim((string) $repository['htmlUrl'], '/') . '/commit/' . $commit,
|
||||
'downloadUrl' => $sourceUrl,
|
||||
'expectedSha256' => '',
|
||||
'commitSha' => $commit,
|
||||
'artifactKind' => 'source_archive',
|
||||
'artifactFilename' => $package . '-' . $version . '-source.tar.gz',
|
||||
'prerelease' => false,
|
||||
'draft' => false,
|
||||
'changelog' => 'Automatisch aus dem freigegebenen Repository-Commit erzeugter Source-Build.',
|
||||
'publishedAt' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
$releaseInfosSeen += count($releaseInfos);
|
||||
$this->http->ensureReleasesCounted($releaseInfosSeen);
|
||||
foreach (array_filter($releaseInfos) as $releaseInfo) {
|
||||
@@ -418,7 +441,8 @@ final class SyncService
|
||||
'releaseUrl' => $this->publicUrl($releaseInfo['releaseUrl'] ?? ''), 'downloadUrl' => $downloadUrl,
|
||||
'sha256' => $artifact['sha256'], 'artifactSize' => (int) $artifact['artifactSize'],
|
||||
'commitSha' => preg_match('/^[a-f0-9]{40}$/', $releaseInfo['commitSha'] ?? '') ? $releaseInfo['commitSha'] : '',
|
||||
'artifactKind' => str_ends_with(strtolower(parse_url($downloadUrl, PHP_URL_PATH) ?: ''), '.whl') ? 'wheel' : 'invalid',
|
||||
'artifactKind' => (string) ($releaseInfo['artifactKind'] ?? (str_ends_with(strtolower(parse_url($downloadUrl, PHP_URL_PATH) ?: ''), '.whl') ? 'wheel' : 'invalid')),
|
||||
'artifactFilename' => (string) ($releaseInfo['artifactFilename'] ?? basename((string) (parse_url($downloadUrl, PHP_URL_PATH) ?: ''))),
|
||||
'prerelease' => (bool) ($releaseInfo['prerelease'] ?? false), 'draft' => (bool) ($releaseInfo['draft'] ?? false),
|
||||
'withdrawn' => false,
|
||||
'changelog' => (string) ($releaseInfo['changelog'] ?? ''), 'publishedAt' => $releaseInfo['publishedAt'] ?? null,
|
||||
|
||||
@@ -68,7 +68,7 @@ include dirname(__DIR__) . '/partials/head.php';
|
||||
<?php foreach (($sources ?? []) as $source): ?>
|
||||
<tr>
|
||||
<td><strong><?= $e($source['name']) ?></strong><small><?= $e($source['baseUrl']) ?></small></td>
|
||||
<td><?= $e($source['provider']) ?> · <?= $e($source['owner']) ?><small>Öffentliche Wheel-Releases</small></td>
|
||||
<td><?= $e($source['provider']) ?> · <?= $e($source['owner']) ?><small>Wheels oder commitgebundener Source-Fallback</small></td>
|
||||
<td><span class="status-pill <?= $e($statusClass($source['status'])) ?>"><?= $e($statusLabel($source['status'])) ?></span></td>
|
||||
<td><?= $e($formatDate($source['lastSyncedAt'] ?? null)) ?></td>
|
||||
<td><div class="action-row">
|
||||
@@ -126,7 +126,7 @@ include dirname(__DIR__) . '/partials/head.php';
|
||||
<table class="admin-table release-admin-table">
|
||||
<thead><tr><th>Plugin / Version</th><th>Artefakt</th><th>Integrität</th><th>Status</th><th>Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
<?php if (($releases ?? []) === []): ?><tr><td colspan="5" class="empty-row">Keine Release-Artefakte gefunden. Veröffentliche ein Wheel als Forgejo-Release-Asset.</td></tr><?php endif; ?>
|
||||
<?php if (($releases ?? []) === []): ?><tr><td colspan="5" class="empty-row">Keine installierbaren Artefakte gefunden. Prüfe Version und Build-Metadaten des Repositorys.</td></tr><?php endif; ?>
|
||||
<?php foreach (($releases ?? []) as $release): ?>
|
||||
<?php $releasePlugin = $release['plugin'] ?? []; $releaseErrors = Approval::releaseErrors($releasePlugin, $release); ?>
|
||||
<tr>
|
||||
|
||||
@@ -59,12 +59,12 @@
|
||||
<?php else: ?>
|
||||
<div class="plugin-grid">
|
||||
<?php foreach ($plugins as $plugin): ?>
|
||||
<?php $release = $plugin['latestRelease'] ?? null; $isWheel = is_array($release) && ($release['artifactKind'] ?? '') === 'wheel'; ?>
|
||||
<?php $release = $plugin['latestRelease'] ?? null; $isWheel = is_array($release) && in_array(($release['artifactKind'] ?? ''), ['wheel', 'source_archive'], true); ?>
|
||||
<article class="plugin-card">
|
||||
<div class="card-topline">
|
||||
<span class="provider-pill"><?= $e(strtoupper((string) ($plugin['source']['provider'] ?? 'git'))) ?></span>
|
||||
<?php if ($isWheel): ?>
|
||||
<span class="status-pill success">Wheel geprüft</span>
|
||||
<span class="status-pill success"><?= ($release['artifactKind'] ?? '') === 'source_archive' ? 'Source freigegeben' : 'Wheel geprüft' ?></span>
|
||||
<?php elseif ($release): ?>
|
||||
<span class="status-pill warning">Kein installierbares Release</span>
|
||||
<?php else: ?>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php declare(strict_types=1); include __DIR__ . '/partials/head.php'; ?>
|
||||
<section class="install-hero">
|
||||
<div class="shell prose-shell">
|
||||
<p class="eyebrow">NetBox 4.6.5–4.6.8</p>
|
||||
<h1>NetBox Store Plugin installieren</h1>
|
||||
<p>Das Plugin verbindet deine NetBox mit <code>https://netbox.mrblake.cc</code>. Docker ist nicht erforderlich.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="shell prose-shell install-guide">
|
||||
<aside class="notice"><strong>Vorher sichern:</strong> Erstelle ein Backup von NetBox und der Datenbank. Führe die Befehle auf dem NetBox-Host aus.</aside>
|
||||
<h2>1. Plugin aus git.mrblake.cc installieren</h2>
|
||||
<p>Trage das Plugin dauerhaft in <code>/opt/netbox/local_requirements.txt</code> ein. Für reproduzierbare Installationen solltest du <code>main</code> durch einen geprüften Commit-SHA ersetzen.</p>
|
||||
<pre><code>sudo sh -c 'printf "%s\n" "netbox-plugin-store @ git+https://git.mrblake.cc/MrBlake/Netbox-Store.git@main#subdirectory=netbox_plugin" >> /opt/netbox/local_requirements.txt'
|
||||
sudo /opt/netbox/upgrade.sh</code></pre>
|
||||
<h2>2. Plugin in NetBox aktivieren</h2>
|
||||
<p>Ergänze die NetBox-Konfiguration:</p>
|
||||
<pre><code>PLUGINS = [
|
||||
"netbox_plugin_store",
|
||||
]
|
||||
|
||||
PLUGINS_CONFIG = {
|
||||
"netbox_plugin_store": {
|
||||
"store_url": "https://netbox.mrblake.cc",
|
||||
"allowed_store_urls": ["https://netbox.mrblake.cc"],
|
||||
"allowed_artifact_urls": [
|
||||
"https://git.mrblake.cc",
|
||||
"https://github.com",
|
||||
"https://codeload.github.com",
|
||||
],
|
||||
"execution_mode": "dry_run",
|
||||
}
|
||||
}</code></pre>
|
||||
<p>Führe danach Migrationen und statische Dateien aus und starte NetBox neu:</p>
|
||||
<pre><code>sudo /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py migrate
|
||||
sudo /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py collectstatic --no-input
|
||||
sudo systemctl restart netbox netbox-rq</code></pre>
|
||||
<h2>3. Sicheren Host-Agent einrichten</h2>
|
||||
<p><code>dry_run</code> verändert das System nicht. Für Installieren, Aktualisieren, Aktivieren und Entfernen wird der mitgelieferte Linux Host-Agent benötigt. Installiere ihn aus <code>host_agent/</code>, prüfe <code>/etc/netbox-store-agent/agent.toml</code> und stelle anschließend <code>execution_mode</code> auf <code>agent</code>.</p>
|
||||
<p>Source-Kandidaten werden nur nach Admin-Freigabe verarbeitet. Der Agent prüft Commitbindung, Größe und SHA-256, baut daraus lokal ein Wheel und installiert nicht direkt aus einem beweglichen Branch.</p>
|
||||
<aside class="notice warning"><strong>Wichtig:</strong> Source-Builds führen den Build-Code des freigegebenen Repository-Commits aus. Gib nur vertrauenswürdige Kandidaten frei.</aside>
|
||||
</section>
|
||||
<?php include __DIR__ . '/partials/footer.php'; ?>
|
||||
@@ -7,6 +7,7 @@
|
||||
<p>Freigegebene Metadaten, reproduzierbar geprüfte Artefakte.</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="/installation">Installation</a>
|
||||
<a href="/api/v1/plugins/">Catalog API v1</a>
|
||||
<a href="/healthz">Systemstatus</a>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
</a>
|
||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||
<a class="<?= ($currentPath ?? '') === '/' ? 'active' : '' ?>" href="/">Store</a>
|
||||
<a class="<?= ($currentPath ?? '') === '/installation' ? 'active' : '' ?>" href="/installation">Installation</a>
|
||||
<a href="/api/v1/plugins/">API</a>
|
||||
<?php if (!empty($adminEnabled)): ?>
|
||||
<a class="<?= str_starts_with((string) ($currentPath ?? ''), '/admin') ? 'active' : '' ?>" href="/admin">Admin</a>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?php declare(strict_types=1); include __DIR__ . '/partials/head.php'; ?>
|
||||
<?php $wheelReleases = array_values(array_filter($releases ?? [], static fn (array $release): bool => ($release['artifactKind'] ?? '') === 'wheel')); ?>
|
||||
<?php $installableReleases = array_values(array_filter($releases ?? [], static fn (array $release): bool => in_array(($release['artifactKind'] ?? ''), ['wheel', 'source_archive'], true))); ?>
|
||||
<section class="detail-hero">
|
||||
<div class="shell">
|
||||
<a class="back-link" href="/">← Alle Plugins</a>
|
||||
@@ -42,10 +42,10 @@
|
||||
|
||||
<section class="side-card releases-card">
|
||||
<div class="side-heading"><h2>Releases</h2><span><?= $e(count($releases ?? [])) ?></span></div>
|
||||
<?php if ($wheelReleases === []): ?>
|
||||
<?php if ($installableReleases === []): ?>
|
||||
<div class="release-warning">
|
||||
<strong>Kein installierbares Release</strong>
|
||||
<p>Für die automatische Installation durch den Host-Agenten fehlt ein freigegebenes <code>.whl</code>-Artefakt.</p>
|
||||
<p>Für die automatische Installation fehlt ein freigegebenes Wheel oder commitgebundenes Source-Artefakt.</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (($releases ?? []) === []): ?>
|
||||
@@ -56,7 +56,7 @@
|
||||
<li>
|
||||
<div><strong><?= $e($release['version']) ?></strong><span><?= $e($formatDate($release['publishedAt'] ?? null)) ?></span></div>
|
||||
<div class="release-tags">
|
||||
<span class="status-pill success">Wheel</span>
|
||||
<span class="status-pill success"><?= ($release['artifactKind'] ?? '') === 'source_archive' ? 'Source-Build' : 'Wheel' ?></span>
|
||||
<span><?= $e($formatBytes($release['artifactSize'])) ?></span>
|
||||
</div>
|
||||
<code class="hash" title="SHA-256"><?= $e(substr($release['sha256'], 0, 16)) ?>…</code>
|
||||
|
||||
+14
-2
@@ -138,6 +138,11 @@ final class FakeAdapter implements SourceAdapter
|
||||
return $this->releases;
|
||||
}
|
||||
|
||||
public function sourceArchiveUrl(array $repository, string $commitSha): ?string
|
||||
{
|
||||
return 'https://git.mrblake.cc/' . $repository['fullName'] . '/archive/' . $commitSha . '.tar.gz';
|
||||
}
|
||||
|
||||
public function hashArtifact(string $url, string $expectedSha256 = ''): array
|
||||
{
|
||||
$this->hashCalls++;
|
||||
@@ -232,6 +237,7 @@ function approvedFixture(): array
|
||||
'title' => '1.2.3', 'downloadUrl' => 'https://git.mrblake.cc/assets/netbox_demo-1.2.3-py3-none-any.whl',
|
||||
'releaseUrl' => 'https://git.mrblake.cc/releases/1', 'sha256' => str_repeat('b', 64),
|
||||
'artifactSize' => 12_345, 'commitSha' => str_repeat('a', 40), 'artifactKind' => 'wheel',
|
||||
'artifactFilename' => 'netbox_demo-1.2.3-py3-none-any.whl',
|
||||
'minNetboxVersion' => '4.6.5', 'maxNetboxVersion' => '4.6.8', 'publishedAt' => '2026-08-20T10:00:00Z',
|
||||
'draft' => false, 'withdrawn' => false, 'status' => 'pending',
|
||||
'approvedAt' => null, 'approvedBy' => null, 'approvedPayloadSha256' => '',
|
||||
@@ -432,7 +438,7 @@ test('Forgejo and GitHub account repository and release pages before accumulatio
|
||||
test('catalog and release-detail API keep the exact client contract', static function () use ($config, $guard, $http): void {
|
||||
$state = approvedFixture();
|
||||
$releaseKeys = [
|
||||
'version', 'download_url', 'sha256', 'artifact_size', 'commit_sha', 'min_netbox_version',
|
||||
'version', 'download_url', 'sha256', 'artifact_size', 'artifact_kind', 'artifact_filename', 'commit_sha', 'min_netbox_version',
|
||||
'max_netbox_version', 'published_at', 'approved', 'status', 'immutable', 'approved_payload_sha256',
|
||||
];
|
||||
$pluginKeys = [
|
||||
@@ -484,6 +490,9 @@ test('public and admin templates render safely with complete artifact evidence',
|
||||
'title' => 'Demo', 'plugin' => $state['plugins'][0], 'source' => $state['sources'][0], 'releases' => $state['releases'],
|
||||
]);
|
||||
assertTrue(str_contains($detail, 'Wheel'));
|
||||
$installation = $view->render('installation', $common + ['title' => 'Installation']);
|
||||
assertTrue(str_contains($installation, 'git+https://git.mrblake.cc/MrBlake/Netbox-Store.git'));
|
||||
assertTrue(str_contains($installation, 'https://netbox.mrblake.cc'));
|
||||
$admin = $view->render('admin/dashboard', [
|
||||
'title' => 'Admin', 'currentPath' => '/admin', 'adminEnabled' => true, 'adminUser' => 'admin',
|
||||
'csrf' => 'safe-token', 'ok' => '', 'error' => '', 'sources' => $state['sources'],
|
||||
@@ -824,9 +833,12 @@ test('sync preserves overrides, rehashes replacements, withdraws removals and ar
|
||||
});
|
||||
$adapter->releases = [];
|
||||
$service->syncSource('source-sync');
|
||||
$withdrawn = $repository->read()['releases'][0];
|
||||
$fallbackState = $repository->read();
|
||||
$withdrawn = $fallbackState['releases'][0];
|
||||
assertTrue($withdrawn['withdrawn']);
|
||||
assertSame('pending', $withdrawn['status']);
|
||||
assertSame('source_archive', $fallbackState['releases'][1]['artifactKind']);
|
||||
assertSame('source:' . str_repeat('c', 40), $fallbackState['releases'][1]['externalId']);
|
||||
|
||||
$adapter->files = [];
|
||||
$service->syncSource('source-sync');
|
||||
|
||||
Reference in New Issue
Block a user