feat: install plugins from approved source commits
CI / php-store (push) Waiting to run
CI / python-components (push) Waiting to run

This commit is contained in:
2026-08-24 21:37:17 +02:00
parent f36d6be511
commit 26aea40e6a
27 changed files with 414 additions and 47 deletions
+12
View File
@@ -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%); }
+25 -3
View File
@@ -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;
}
}
+2
View File
@@ -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'] ?? '')),
+3
View File
@@ -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]);
}
+11
View File
@@ -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
{
+10
View File
@@ -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
{
+3
View File
@@ -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;
}
+27 -3
View File
@@ -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,
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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: ?>
+42
View File
@@ -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.54.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" &gt;&gt; /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'; ?>
+1
View File
@@ -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>
+1
View File
@@ -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>
+4 -4
View File
@@ -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
View File
@@ -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');