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. |
|
| `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. |
|
| `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.
|
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.
|
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:
|
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
|
## 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
|
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,
|
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`.
|
with `shell=False`.
|
||||||
|
|
||||||
## Install and one-time operator setup
|
## 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:
|
The agent owns only these two configured files:
|
||||||
|
|
||||||
- `paths.include_path`, which contains only `STORE_PLUGINS = [...]`;
|
- `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:
|
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.
|
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
|
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.
|
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
|
Redirects, source distributions, private/reserved DNS targets (unless explicitly enabled for a test
|
||||||
environment), and catalog additions outside the v1 schema fail closed.
|
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
|
- `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`.
|
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`,
|
- New installs remain disabled. Updating an enabled plugin runs NetBox `migrate`, `collectstatic`,
|
||||||
and restarts every configured service (`netbox` and `netbox-rq` in the example).
|
and restarts every configured service (`netbox` and `netbox-rq` in the example).
|
||||||
- `enable` revalidates its installed release, writes the include, migrates, collects static files,
|
- `enable` revalidates its installed release, writes the include, migrates, collects static files,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ connection_timeout_seconds = 5
|
|||||||
worker_threads = 1
|
worker_threads = 1
|
||||||
|
|
||||||
[store]
|
[store]
|
||||||
base_url = "https://store.example.invalid"
|
base_url = "https://netbox.mrblake.cc"
|
||||||
plugin_endpoint_template = "/api/v1/plugins/{plugin_slug}/"
|
plugin_endpoint_template = "/api/v1/plugins/{plugin_slug}/"
|
||||||
release_endpoint_template = "/api/v1/plugins/{plugin_slug}/releases/{version}/"
|
release_endpoint_template = "/api/v1/plugins/{plugin_slug}/releases/{version}/"
|
||||||
timeout_seconds = 10
|
timeout_seconds = 10
|
||||||
@@ -26,7 +26,7 @@ max_artifact_bytes = 268435456
|
|||||||
allow_private_addresses = false
|
allow_private_addresses = false
|
||||||
allow_http_for_testing = false
|
allow_http_for_testing = false
|
||||||
# Add an artifact origin only when releases intentionally use that origin.
|
# 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"
|
# bearer_token_file = "/etc/netbox-store-agent/store.token"
|
||||||
# ca_file = "/etc/ssl/certs/internal-store-ca.pem"
|
# 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"
|
systemctl_path = "/usr/bin/systemctl"
|
||||||
services = ["netbox", "netbox-rq"]
|
services = ["netbox", "netbox-rq"]
|
||||||
command_timeout_seconds = 900
|
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]
|
[policy]
|
||||||
netbox_version = "4.6.8"
|
netbox_version = "4.6.8"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import os
|
|||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
import stat
|
import stat
|
||||||
|
import tarfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -264,6 +265,8 @@ class ReleasePlan:
|
|||||||
artifact_sha256: str
|
artifact_sha256: str
|
||||||
artifact_size: int
|
artifact_size: int
|
||||||
approved_payload_sha256: str
|
approved_payload_sha256: str
|
||||||
|
artifact_kind: str = "wheel"
|
||||||
|
artifact_filename: str = ""
|
||||||
|
|
||||||
def requirement(self) -> dict[str, Any]:
|
def requirement(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -275,6 +278,14 @@ class ReleasePlan:
|
|||||||
"size": self.artifact_size,
|
"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]:
|
def _object(value: Any, context: str) -> dict[str, Any]:
|
||||||
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||||
@@ -321,6 +332,8 @@ class StoreClient:
|
|||||||
"download_url",
|
"download_url",
|
||||||
"sha256",
|
"sha256",
|
||||||
"artifact_size",
|
"artifact_size",
|
||||||
|
"artifact_kind",
|
||||||
|
"artifact_filename",
|
||||||
"commit_sha",
|
"commit_sha",
|
||||||
"min_netbox_version",
|
"min_netbox_version",
|
||||||
"max_netbox_version",
|
"max_netbox_version",
|
||||||
@@ -432,12 +445,26 @@ class StoreClient:
|
|||||||
raise CatalogError("release.commit_sha is invalid")
|
raise CatalogError("release.commit_sha is invalid")
|
||||||
if data["published_at"] is not None:
|
if data["published_at"] is not None:
|
||||||
_bounded_string(data["published_at"], "release.published_at", 100)
|
_bounded_string(data["published_at"], "release.published_at", 100)
|
||||||
parsed_url = urlsplit(download_url)
|
artifact_kind = _bounded_string(data["artifact_kind"], "release.artifact_kind", 32)
|
||||||
filename = Path(parsed_url.path).name
|
if artifact_kind not in {"wheel", "source_archive"}:
|
||||||
if not filename or not filename.endswith(".whl") or len(filename) > 255:
|
raise CatalogError("release.artifact_kind is unsupported")
|
||||||
raise CatalogError("approved artifact must be a wheel with a safe filename")
|
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.
|
# 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:
|
def get_release(self, slug: str, version: str) -> ReleasePlan:
|
||||||
plugin = self.get_plugin(slug)
|
plugin = self.get_plugin(slug)
|
||||||
@@ -464,8 +491,15 @@ class StoreClient:
|
|||||||
expected_size=plan.artifact_size,
|
expected_size=plan.artifact_size,
|
||||||
expected_sha256=plan.artifact_sha256,
|
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:
|
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:
|
except (InvalidVersion, ValueError) as exc:
|
||||||
raise CatalogError("artifact filename is not a valid wheel filename") from exc
|
raise CatalogError("artifact filename is not a valid wheel filename") from exc
|
||||||
if canonicalize_name(distribution) != canonicalize_name(plan.plugin.package_name):
|
if canonicalize_name(distribution) != canonicalize_name(plan.plugin.package_name):
|
||||||
@@ -494,4 +528,36 @@ class StoreClient:
|
|||||||
raise CatalogError("wheel archive failed its integrity check")
|
raise CatalogError("wheel archive failed its integrity check")
|
||||||
except (OSError, zipfile.BadZipFile) as exc:
|
except (OSError, zipfile.BadZipFile) as exc:
|
||||||
raise CatalogError("artifact is not a valid wheel archive") from 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
|
systemctl_path: Path
|
||||||
services: tuple[str, ...]
|
services: tuple[str, ...]
|
||||||
command_timeout_seconds: int
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -240,7 +244,11 @@ def load_config(path: str | Path, *, allow_insecure_owner: bool = False) -> Conf
|
|||||||
commands_data = _table(
|
commands_data = _table(
|
||||||
root,
|
root,
|
||||||
"commands",
|
"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(
|
policy_data = _table(
|
||||||
root,
|
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"),
|
systemctl_path=require_absolute_path(_string(commands_data, "systemctl_path"), "systemctl_path"),
|
||||||
services=services,
|
services=services,
|
||||||
command_timeout_seconds=_int(commands_data, "command_timeout_seconds", 900, 1, 7200),
|
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] = {}
|
versions: dict[str, str] = {}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import shutil
|
||||||
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
@@ -56,7 +60,11 @@ class OperationProcessor:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _same_release(existing: ManagedPlugin, plan: ReleasePlan) -> None:
|
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(
|
raise PolicyError(
|
||||||
"managed artifact lock differs from the current immutable Store release"
|
"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:
|
def _pip_uninstall(self, operation_id: str, package_name: str) -> None:
|
||||||
self._run(
|
self._run(
|
||||||
operation_id,
|
operation_id,
|
||||||
@@ -184,23 +257,26 @@ class OperationProcessor:
|
|||||||
if existing is not None:
|
if existing is not None:
|
||||||
self._same_identity(existing, plan.plugin)
|
self._same_identity(existing, plan.plugin)
|
||||||
enabled = existing.enabled if existing else False
|
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
|
temp_root = self.config.paths.temp_dir
|
||||||
if temp_root.is_symlink():
|
if temp_root.is_symlink():
|
||||||
raise PolicyError("temporary directory may not be a symlink")
|
raise PolicyError("temporary directory may not be a symlink")
|
||||||
temp_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
temp_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
with tempfile.TemporaryDirectory(prefix="operation-", dir=temp_root) as temporary:
|
with tempfile.TemporaryDirectory(prefix="operation-", dir=temp_root) as temporary:
|
||||||
self._event(operation_id, "artifact", "Downloading and verifying approved wheel")
|
self._event(operation_id, "artifact", "Downloading and verifying approved artifact")
|
||||||
wheel = self.store.download_release(plan, Path(temporary))
|
artifact = self.store.download_release(plan, Path(temporary))
|
||||||
if self.config.agent.dry_run:
|
if self.config.agent.dry_run:
|
||||||
return self._dry_result(request, version=plan.version, artifact_verified=True), False
|
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)
|
self._mark_mutated(operation_id)
|
||||||
host_mutated = True
|
host_mutated = True
|
||||||
self._pip_install(operation_id, Path(temporary), managed, wheel)
|
self._pip_install(operation_id, Path(temporary), managed, wheel)
|
||||||
@@ -239,6 +315,7 @@ class OperationProcessor:
|
|||||||
existing.version,
|
existing.version,
|
||||||
True,
|
True,
|
||||||
existing.requirements,
|
existing.requirements,
|
||||||
|
existing.artifact_lock,
|
||||||
)
|
)
|
||||||
if self.config.agent.dry_run:
|
if self.config.agent.dry_run:
|
||||||
return self._dry_result(request, version=existing.version, enabled=True), False
|
return self._dry_result(request, version=existing.version, enabled=True), False
|
||||||
@@ -260,6 +337,7 @@ class OperationProcessor:
|
|||||||
existing.version,
|
existing.version,
|
||||||
False,
|
False,
|
||||||
existing.requirements,
|
existing.requirements,
|
||||||
|
existing.artifact_lock,
|
||||||
)
|
)
|
||||||
if self.config.agent.dry_run:
|
if self.config.agent.dry_run:
|
||||||
return self._dry_result(request, version=existing.version, enabled=False), False
|
return self._dry_result(request, version=existing.version, enabled=False), False
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class ManagedPlugin:
|
|||||||
version: str
|
version: str
|
||||||
enabled: bool
|
enabled: bool
|
||||||
requirements: tuple[dict[str, Any], ...]
|
requirements: tuple[dict[str, Any], ...]
|
||||||
|
artifact_lock: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class Journal:
|
class Journal:
|
||||||
@@ -111,10 +112,17 @@ class Journal:
|
|||||||
version TEXT NOT NULL,
|
version TEXT NOT NULL,
|
||||||
enabled INTEGER NOT NULL CHECK(enabled IN (0, 1)),
|
enabled INTEGER NOT NULL CHECK(enabled IN (0, 1)),
|
||||||
requirements_json TEXT NOT NULL,
|
requirements_json TEXT NOT NULL,
|
||||||
|
artifact_lock_json TEXT NOT NULL DEFAULT '{}',
|
||||||
updated_at TEXT NOT NULL
|
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
|
@staticmethod
|
||||||
def _operation_dict(row: sqlite3.Row, events: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
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"],
|
version=row["version"],
|
||||||
enabled=bool(row["enabled"]),
|
enabled=bool(row["enabled"]),
|
||||||
requirements=tuple(requirements),
|
requirements=tuple(requirements),
|
||||||
|
artifact_lock=json.loads(row["artifact_lock_json"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_managed_plugin(self, slug: str) -> ManagedPlugin | None:
|
def get_managed_plugin(self, slug: str) -> ManagedPlugin | None:
|
||||||
@@ -322,18 +331,20 @@ class Journal:
|
|||||||
|
|
||||||
def upsert_managed_plugin(self, plugin: ManagedPlugin) -> None:
|
def upsert_managed_plugin(self, plugin: ManagedPlugin) -> None:
|
||||||
requirements_json = canonical_json(list(plugin.requirements))
|
requirements_json = canonical_json(list(plugin.requirements))
|
||||||
|
artifact_lock_json = canonical_json(plugin.artifact_lock)
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO managed_plugins(
|
INSERT INTO managed_plugins(
|
||||||
slug, package_name, import_name, version, enabled, requirements_json, updated_at
|
slug, package_name, import_name, version, enabled, requirements_json, artifact_lock_json, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(slug) DO UPDATE SET
|
ON CONFLICT(slug) DO UPDATE SET
|
||||||
package_name=excluded.package_name,
|
package_name=excluded.package_name,
|
||||||
import_name=excluded.import_name,
|
import_name=excluded.import_name,
|
||||||
version=excluded.version,
|
version=excluded.version,
|
||||||
enabled=excluded.enabled,
|
enabled=excluded.enabled,
|
||||||
requirements_json=excluded.requirements_json,
|
requirements_json=excluded.requirements_json,
|
||||||
|
artifact_lock_json=excluded.artifact_lock_json,
|
||||||
updated_at=excluded.updated_at
|
updated_at=excluded.updated_at
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
@@ -343,6 +354,7 @@ class Journal:
|
|||||||
plugin.version,
|
plugin.version,
|
||||||
int(plugin.enabled),
|
int(plugin.enabled),
|
||||||
requirements_json,
|
requirements_json,
|
||||||
|
artifact_lock_json,
|
||||||
utc_now(),
|
utc_now(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ class ManagedFiles:
|
|||||||
def render_requirements(self, plugins: Iterable[ManagedPlugin]) -> bytes:
|
def render_requirements(self, plugins: Iterable[ManagedPlugin]) -> bytes:
|
||||||
requirements: dict[str, dict[str, object]] = {}
|
requirements: dict[str, dict[str, object]] = {}
|
||||||
schemes = {"https", "http"} if self.config.store.allow_http_for_testing else {"https"}
|
schemes = {"https", "http"} if self.config.store.allow_http_for_testing else {"https"}
|
||||||
|
schemes.add("file")
|
||||||
for plugin in plugins:
|
for plugin in plugins:
|
||||||
for raw in plugin.requirements:
|
for raw in plugin.requirements:
|
||||||
if not isinstance(raw, dict):
|
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 "):
|
if not isinstance(url, str) or any(char in url for char in "\r\n\t "):
|
||||||
raise ValidationError("managed requirement URL is invalid")
|
raise ValidationError("managed requirement URL is invalid")
|
||||||
parsed = urlsplit(url)
|
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 (
|
if (
|
||||||
parsed.scheme not in schemes
|
parsed.scheme not in schemes
|
||||||
or not parsed.hostname
|
or remote_invalid
|
||||||
or parsed.hostname.lower() not in self.config.store.allowed_hosts
|
or local_invalid
|
||||||
or parsed.username
|
or parsed.username
|
||||||
or parsed.password
|
or parsed.password
|
||||||
or parsed.fragment
|
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",
|
"download_url": f"http://artifacts.test/demo_plugin-{version}-py3-none-any.whl",
|
||||||
"sha256": "a" * 64,
|
"sha256": "a" * 64,
|
||||||
"artifact_size": 100,
|
"artifact_size": 100,
|
||||||
|
"artifact_kind": "wheel",
|
||||||
|
"artifact_filename": f"demo_plugin-{version}-py3-none-any.whl",
|
||||||
"commit_sha": "",
|
"commit_sha": "",
|
||||||
"min_netbox_version": "4.6.5",
|
"min_netbox_version": "4.6.5",
|
||||||
"max_netbox_version": "4.6.8",
|
"max_netbox_version": "4.6.8",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -48,6 +50,36 @@ class CatalogTests(unittest.TestCase):
|
|||||||
plan = StoreClient(self.config, transport).get_release("demo-plugin", "1.2.3")
|
plan = StoreClient(self.config, transport).get_release("demo-plugin", "1.2.3")
|
||||||
self.assertEqual(plan.version, "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:
|
def test_unapproved_or_mutable_release_rejected(self) -> None:
|
||||||
for change in ({"approved": False}, {"immutable": False}, {"status": "pending"}):
|
for change in ({"approved": False}, {"immutable": False}, {"status": "pending"}):
|
||||||
with self.subTest(change=change):
|
with self.subTest(change=change):
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ class Release:
|
|||||||
approved: bool
|
approved: bool
|
||||||
immutable: bool
|
immutable: bool
|
||||||
approved_payload_sha256: str
|
approved_payload_sha256: str
|
||||||
|
artifact_kind: str = "wheel"
|
||||||
|
artifact_filename: str = ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_mapping(cls, value: dict[str, Any]) -> "Release":
|
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",
|
approved=value.get("approved") is True or value.get("status") == "approved",
|
||||||
immutable=value.get("immutable") is True,
|
immutable=value.get("immutable") is True,
|
||||||
approved_payload_sha256=approved_payload_sha256,
|
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:
|
def supports(self, netbox_version: str, plugin: "CatalogPlugin") -> bool:
|
||||||
|
|||||||
@@ -235,6 +235,8 @@ class LifecycleService:
|
|||||||
raise LifecycleError("Approved release has no artifact URL.")
|
raise LifecycleError("Approved release has no artifact URL.")
|
||||||
if not release.sha256:
|
if not release.sha256:
|
||||||
raise LifecycleError("Approved release has no artifact SHA-256. Commit hashes are not accepted.")
|
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
|
@staticmethod
|
||||||
def _assert_state(action: str, installed: str, enabled: bool, runtime_active: bool) -> None:
|
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"
|
return suffix if re.fullmatch(r"\.[A-Za-z0-9.]{1,16}", suffix) else ".artifact"
|
||||||
|
|
||||||
def _download(self, release: Release, directory: Path) -> Path:
|
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)}"
|
artifact = directory / f"artifact{self._artifact_suffix(release)}"
|
||||||
self.client.download_artifact(
|
self.client.download_artifact(
|
||||||
release.download_url,
|
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 ul { margin: 7px 0 0; padding-left: 18px; }
|
||||||
.sync-errors li { display: list-item; padding: 3px 0; border: 0; overflow-wrap: anywhere; }
|
.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) {
|
@media (max-width: 980px) {
|
||||||
.hero-grid { grid-template-columns: 1fr; gap: 35px; }
|
.hero-grid { grid-template-columns: 1fr; gap: 35px; }
|
||||||
.hero-stat { width: min(350px, 100%); }
|
.hero-stat { width: min(350px, 100%); }
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ final class Approval
|
|||||||
'commitSha' => strtolower((string) ($release['commitSha'] ?? '')),
|
'commitSha' => strtolower((string) ($release['commitSha'] ?? '')),
|
||||||
'downloadUrl' => (string) ($release['downloadUrl'] ?? ''),
|
'downloadUrl' => (string) ($release['downloadUrl'] ?? ''),
|
||||||
'artifactKind' => (string) ($release['artifactKind'] ?? ''),
|
'artifactKind' => (string) ($release['artifactKind'] ?? ''),
|
||||||
|
'artifactFilename' => (string) ($release['artifactFilename'] ?? ''),
|
||||||
'importName' => (string) ($plugin['importName'] ?? ''),
|
'importName' => (string) ($plugin['importName'] ?? ''),
|
||||||
'maxNetboxVersion' => (string) (($release['maxNetboxVersion'] ?? '') ?: ($plugin['maxNetboxVersion'] ?? '')),
|
'maxNetboxVersion' => (string) (($release['maxNetboxVersion'] ?? '') ?: ($plugin['maxNetboxVersion'] ?? '')),
|
||||||
'minNetboxVersion' => (string) (($release['minNetboxVersion'] ?? '') ?: ($plugin['minNetboxVersion'] ?? '')),
|
'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'])) {
|
if (!is_array($url) || ($url['scheme'] ?? '') !== 'https' || empty($url['host']) || isset($url['user']) || isset($url['pass'])) {
|
||||||
$errors[] = 'Download-URL muss credential-freies HTTPS sein.';
|
$errors[] = 'Download-URL muss credential-freies HTTPS sein.';
|
||||||
} else {
|
} 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'] ?? '');
|
$commit = (string) ($release['commitSha'] ?? '');
|
||||||
if ($commit !== '' && preg_match('/^[a-f0-9]{40}$/', $commit) !== 1) {
|
if ($commit !== '' && preg_match('/^[a-f0-9]{40}$/', $commit) !== 1) {
|
||||||
@@ -109,8 +113,8 @@ final class Approval
|
|||||||
if (!self::immutable($release)) {
|
if (!self::immutable($release)) {
|
||||||
$errors[] = 'Artefakt-SHA oder Größe fehlt.';
|
$errors[] = 'Artefakt-SHA oder Größe fehlt.';
|
||||||
}
|
}
|
||||||
if (($release['artifactKind'] ?? '') !== 'wheel') {
|
if (!in_array(($release['artifactKind'] ?? ''), ['wheel', 'source_archive'], true)) {
|
||||||
$errors[] = 'Im API-v1-Katalog sind ausschließlich Wheel-Artefakte freigabefähig.';
|
$errors[] = 'Nur Wheel- oder commitgebundene Source-Artefakte sind freigabefähig.';
|
||||||
}
|
}
|
||||||
if (!empty($release['draft']) || !empty($release['withdrawn'])) {
|
if (!empty($release['draft']) || !empty($release['withdrawn'])) {
|
||||||
$errors[] = 'Drafts oder zurückgezogene Releases sind nicht freigabefähig.';
|
$errors[] = 'Drafts oder zurückgezogene Releases sind nicht freigabefähig.';
|
||||||
@@ -259,4 +263,22 @@ final class Approval
|
|||||||
}
|
}
|
||||||
return $errors;
|
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'],
|
'download_url' => (string) $release['downloadUrl'],
|
||||||
'sha256' => strtolower((string) $release['sha256']),
|
'sha256' => strtolower((string) $release['sha256']),
|
||||||
'artifact_size' => (int) $release['artifactSize'],
|
'artifact_size' => (int) $release['artifactSize'],
|
||||||
|
'artifact_kind' => (string) $release['artifactKind'],
|
||||||
|
'artifact_filename' => (string) $release['artifactFilename'],
|
||||||
'commit_sha' => (string) ($release['commitSha'] ?? ''),
|
'commit_sha' => (string) ($release['commitSha'] ?? ''),
|
||||||
'min_netbox_version' => (string) (($release['minNetboxVersion'] ?? '') ?: ($plugin['minNetboxVersion'] ?? '')),
|
'min_netbox_version' => (string) (($release['minNetboxVersion'] ?? '') ?: ($plugin['minNetboxVersion'] ?? '')),
|
||||||
'max_netbox_version' => (string) (($release['maxNetboxVersion'] ?? '') ?: ($plugin['maxNetboxVersion'] ?? '')),
|
'max_netbox_version' => (string) (($release['maxNetboxVersion'] ?? '') ?: ($plugin['maxNetboxVersion'] ?? '')),
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ final class Application
|
|||||||
if ($request->method === 'GET' && $request->path === '/') {
|
if ($request->method === 'GET' && $request->path === '/') {
|
||||||
return $this->home($request);
|
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)) {
|
if ($request->method === 'GET' && preg_match('#^/plugins/([^/]+)$#', $request->path, $match)) {
|
||||||
return $this->plugin($request, $match[1]);
|
return $this->plugin($request, $match[1]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ final class ForgejoAdapter extends AbstractAdapter
|
|||||||
'archived' => (bool) ($item['archived'] ?? false),
|
'archived' => (bool) ($item['archived'] ?? false),
|
||||||
'fork' => (bool) ($item['fork'] ?? false),
|
'fork' => (bool) ($item['fork'] ?? false),
|
||||||
'empty' => (bool) ($item['empty'] ?? false),
|
'empty' => (bool) ($item['empty'] ?? false),
|
||||||
|
'private' => (bool) ($item['private'] ?? false),
|
||||||
];
|
];
|
||||||
$this->validateRepository($repository);
|
$this->validateRepository($repository);
|
||||||
return $repository;
|
return $repository;
|
||||||
@@ -135,6 +136,16 @@ final class ForgejoAdapter extends AbstractAdapter
|
|||||||
return $releases;
|
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 */
|
/** @return list<array<string,mixed>>|null */
|
||||||
private function pagedRepositories(string $endpoint): ?array
|
private function pagedRepositories(string $endpoint): ?array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -138,6 +138,16 @@ final class GitHubAdapter extends AbstractAdapter
|
|||||||
return $releases;
|
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 */
|
/** @return list<array<string,mixed>>|null */
|
||||||
private function paged(string $endpoint): ?array
|
private function paged(string $endpoint): ?array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ interface SourceAdapter
|
|||||||
/** @param array<string,mixed> $repository @return list<array<string,mixed>> */
|
/** @param array<string,mixed> $repository @return list<array<string,mixed>> */
|
||||||
public function listReleases(array $repository): array;
|
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} */
|
/** @return array{sha256:string,artifactSize:int} */
|
||||||
public function hashArtifact(string $url, string $expectedSha256 = ''): array;
|
public function hashArtifact(string $url, string $expectedSha256 = ''): array;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use Closure;
|
|||||||
final class SyncService
|
final class SyncService
|
||||||
{
|
{
|
||||||
private const PLUGIN_SECURITY = ['packageName', 'importName', 'minNetboxVersion', 'maxNetboxVersion'];
|
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(
|
public function __construct(
|
||||||
private readonly StoreRepository $repository,
|
private readonly StoreRepository $repository,
|
||||||
@@ -199,7 +199,7 @@ final class SyncService
|
|||||||
// GitHub's browser_download_url is deliberately public-only.
|
// GitHub's browser_download_url is deliberately public-only.
|
||||||
// Provider credentials stay on the API origin and the v1
|
// Provider credentials stay on the API origin and the v1
|
||||||
// Host-Agent has no credentials for private asset downloads.
|
// 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'], []);
|
$this->withdrawMissingReleases((string) $plugin['id'], []);
|
||||||
$run['errors'][] = [
|
$run['errors'][] = [
|
||||||
'repository' => $repository['fullName'],
|
'repository' => $repository['fullName'],
|
||||||
@@ -209,6 +209,29 @@ final class SyncService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$releaseInfos = $adapter->listReleases($result['repository']);
|
$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);
|
$releaseInfosSeen += count($releaseInfos);
|
||||||
$this->http->ensureReleasesCounted($releaseInfosSeen);
|
$this->http->ensureReleasesCounted($releaseInfosSeen);
|
||||||
foreach (array_filter($releaseInfos) as $releaseInfo) {
|
foreach (array_filter($releaseInfos) as $releaseInfo) {
|
||||||
@@ -418,7 +441,8 @@ final class SyncService
|
|||||||
'releaseUrl' => $this->publicUrl($releaseInfo['releaseUrl'] ?? ''), 'downloadUrl' => $downloadUrl,
|
'releaseUrl' => $this->publicUrl($releaseInfo['releaseUrl'] ?? ''), 'downloadUrl' => $downloadUrl,
|
||||||
'sha256' => $artifact['sha256'], 'artifactSize' => (int) $artifact['artifactSize'],
|
'sha256' => $artifact['sha256'], 'artifactSize' => (int) $artifact['artifactSize'],
|
||||||
'commitSha' => preg_match('/^[a-f0-9]{40}$/', $releaseInfo['commitSha'] ?? '') ? $releaseInfo['commitSha'] : '',
|
'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),
|
'prerelease' => (bool) ($releaseInfo['prerelease'] ?? false), 'draft' => (bool) ($releaseInfo['draft'] ?? false),
|
||||||
'withdrawn' => false,
|
'withdrawn' => false,
|
||||||
'changelog' => (string) ($releaseInfo['changelog'] ?? ''), 'publishedAt' => $releaseInfo['publishedAt'] ?? null,
|
'changelog' => (string) ($releaseInfo['changelog'] ?? ''), 'publishedAt' => $releaseInfo['publishedAt'] ?? null,
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ include dirname(__DIR__) . '/partials/head.php';
|
|||||||
<?php foreach (($sources ?? []) as $source): ?>
|
<?php foreach (($sources ?? []) as $source): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong><?= $e($source['name']) ?></strong><small><?= $e($source['baseUrl']) ?></small></td>
|
<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><span class="status-pill <?= $e($statusClass($source['status'])) ?>"><?= $e($statusLabel($source['status'])) ?></span></td>
|
||||||
<td><?= $e($formatDate($source['lastSyncedAt'] ?? null)) ?></td>
|
<td><?= $e($formatDate($source['lastSyncedAt'] ?? null)) ?></td>
|
||||||
<td><div class="action-row">
|
<td><div class="action-row">
|
||||||
@@ -126,7 +126,7 @@ include dirname(__DIR__) . '/partials/head.php';
|
|||||||
<table class="admin-table release-admin-table">
|
<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>
|
<thead><tr><th>Plugin / Version</th><th>Artefakt</th><th>Integrität</th><th>Status</th><th>Aktionen</th></tr></thead>
|
||||||
<tbody>
|
<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 foreach (($releases ?? []) as $release): ?>
|
||||||
<?php $releasePlugin = $release['plugin'] ?? []; $releaseErrors = Approval::releaseErrors($releasePlugin, $release); ?>
|
<?php $releasePlugin = $release['plugin'] ?? []; $releaseErrors = Approval::releaseErrors($releasePlugin, $release); ?>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -59,12 +59,12 @@
|
|||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="plugin-grid">
|
<div class="plugin-grid">
|
||||||
<?php foreach ($plugins as $plugin): ?>
|
<?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">
|
<article class="plugin-card">
|
||||||
<div class="card-topline">
|
<div class="card-topline">
|
||||||
<span class="provider-pill"><?= $e(strtoupper((string) ($plugin['source']['provider'] ?? 'git'))) ?></span>
|
<span class="provider-pill"><?= $e(strtoupper((string) ($plugin['source']['provider'] ?? 'git'))) ?></span>
|
||||||
<?php if ($isWheel): ?>
|
<?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): ?>
|
<?php elseif ($release): ?>
|
||||||
<span class="status-pill warning">Kein installierbares Release</span>
|
<span class="status-pill warning">Kein installierbares Release</span>
|
||||||
<?php else: ?>
|
<?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>
|
<p>Freigegebene Metadaten, reproduzierbar geprüfte Artefakte.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
|
<a href="/installation">Installation</a>
|
||||||
<a href="/api/v1/plugins/">Catalog API v1</a>
|
<a href="/api/v1/plugins/">Catalog API v1</a>
|
||||||
<a href="/healthz">Systemstatus</a>
|
<a href="/healthz">Systemstatus</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||||
<a class="<?= ($currentPath ?? '') === '/' ? 'active' : '' ?>" href="/">Store</a>
|
<a class="<?= ($currentPath ?? '') === '/' ? 'active' : '' ?>" href="/">Store</a>
|
||||||
|
<a class="<?= ($currentPath ?? '') === '/installation' ? 'active' : '' ?>" href="/installation">Installation</a>
|
||||||
<a href="/api/v1/plugins/">API</a>
|
<a href="/api/v1/plugins/">API</a>
|
||||||
<?php if (!empty($adminEnabled)): ?>
|
<?php if (!empty($adminEnabled)): ?>
|
||||||
<a class="<?= str_starts_with((string) ($currentPath ?? ''), '/admin') ? 'active' : '' ?>" href="/admin">Admin</a>
|
<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 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">
|
<section class="detail-hero">
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<a class="back-link" href="/">← Alle Plugins</a>
|
<a class="back-link" href="/">← Alle Plugins</a>
|
||||||
@@ -42,10 +42,10 @@
|
|||||||
|
|
||||||
<section class="side-card releases-card">
|
<section class="side-card releases-card">
|
||||||
<div class="side-heading"><h2>Releases</h2><span><?= $e(count($releases ?? [])) ?></span></div>
|
<div class="side-heading"><h2>Releases</h2><span><?= $e(count($releases ?? [])) ?></span></div>
|
||||||
<?php if ($wheelReleases === []): ?>
|
<?php if ($installableReleases === []): ?>
|
||||||
<div class="release-warning">
|
<div class="release-warning">
|
||||||
<strong>Kein installierbares Release</strong>
|
<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>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (($releases ?? []) === []): ?>
|
<?php if (($releases ?? []) === []): ?>
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<div><strong><?= $e($release['version']) ?></strong><span><?= $e($formatDate($release['publishedAt'] ?? null)) ?></span></div>
|
<div><strong><?= $e($release['version']) ?></strong><span><?= $e($formatDate($release['publishedAt'] ?? null)) ?></span></div>
|
||||||
<div class="release-tags">
|
<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>
|
<span><?= $e($formatBytes($release['artifactSize'])) ?></span>
|
||||||
</div>
|
</div>
|
||||||
<code class="hash" title="SHA-256"><?= $e(substr($release['sha256'], 0, 16)) ?>…</code>
|
<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;
|
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
|
public function hashArtifact(string $url, string $expectedSha256 = ''): array
|
||||||
{
|
{
|
||||||
$this->hashCalls++;
|
$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',
|
'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),
|
'releaseUrl' => 'https://git.mrblake.cc/releases/1', 'sha256' => str_repeat('b', 64),
|
||||||
'artifactSize' => 12_345, 'commitSha' => str_repeat('a', 40), 'artifactKind' => 'wheel',
|
'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',
|
'minNetboxVersion' => '4.6.5', 'maxNetboxVersion' => '4.6.8', 'publishedAt' => '2026-08-20T10:00:00Z',
|
||||||
'draft' => false, 'withdrawn' => false, 'status' => 'pending',
|
'draft' => false, 'withdrawn' => false, 'status' => 'pending',
|
||||||
'approvedAt' => null, 'approvedBy' => null, 'approvedPayloadSha256' => '',
|
'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 {
|
test('catalog and release-detail API keep the exact client contract', static function () use ($config, $guard, $http): void {
|
||||||
$state = approvedFixture();
|
$state = approvedFixture();
|
||||||
$releaseKeys = [
|
$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',
|
'max_netbox_version', 'published_at', 'approved', 'status', 'immutable', 'approved_payload_sha256',
|
||||||
];
|
];
|
||||||
$pluginKeys = [
|
$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'],
|
'title' => 'Demo', 'plugin' => $state['plugins'][0], 'source' => $state['sources'][0], 'releases' => $state['releases'],
|
||||||
]);
|
]);
|
||||||
assertTrue(str_contains($detail, 'Wheel'));
|
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', [
|
$admin = $view->render('admin/dashboard', [
|
||||||
'title' => 'Admin', 'currentPath' => '/admin', 'adminEnabled' => true, 'adminUser' => 'admin',
|
'title' => 'Admin', 'currentPath' => '/admin', 'adminEnabled' => true, 'adminUser' => 'admin',
|
||||||
'csrf' => 'safe-token', 'ok' => '', 'error' => '', 'sources' => $state['sources'],
|
'csrf' => 'safe-token', 'ok' => '', 'error' => '', 'sources' => $state['sources'],
|
||||||
@@ -824,9 +833,12 @@ test('sync preserves overrides, rehashes replacements, withdraws removals and ar
|
|||||||
});
|
});
|
||||||
$adapter->releases = [];
|
$adapter->releases = [];
|
||||||
$service->syncSource('source-sync');
|
$service->syncSource('source-sync');
|
||||||
$withdrawn = $repository->read()['releases'][0];
|
$fallbackState = $repository->read();
|
||||||
|
$withdrawn = $fallbackState['releases'][0];
|
||||||
assertTrue($withdrawn['withdrawn']);
|
assertTrue($withdrawn['withdrawn']);
|
||||||
assertSame('pending', $withdrawn['status']);
|
assertSame('pending', $withdrawn['status']);
|
||||||
|
assertSame('source_archive', $fallbackState['releases'][1]['artifactKind']);
|
||||||
|
assertSame('source:' . str_repeat('c', 40), $fallbackState['releases'][1]['externalId']);
|
||||||
|
|
||||||
$adapter->files = [];
|
$adapter->files = [];
|
||||||
$service->syncSource('source-sync');
|
$service->syncSource('source-sync');
|
||||||
|
|||||||
Reference in New Issue
Block a user