Improve artifact availability UX
CI / php-store (push) Waiting to run
CI / python-components (push) Waiting to run

This commit is contained in:
2026-08-24 22:42:56 +02:00
parent 3028bf522c
commit 0440b6a48f
12 changed files with 85 additions and 23 deletions
+1 -1
View File
@@ -232,7 +232,7 @@ Das Plugin unter `netbox_plugin/` ist auf NetBox 4.6.54.6.8 begrenzt. Zuerst
```text ```text
cd netbox_plugin cd netbox_plugin
python -m build python -m build
/opt/netbox/venv/bin/pip install dist/netbox_plugin_store-0.1.1-py3-none-any.whl /opt/netbox/venv/bin/pip install dist/netbox_plugin_store-0.1.2-py3-none-any.whl
``` ```
Das Paket muss außerdem in `/opt/netbox/local_requirements.txt` festgehalten werden. In `configuration.py` wird es zunächst sicher im Dry-run-Modus eingerichtet: Das Paket muss außerdem in `/opt/netbox/local_requirements.txt` festgehalten werden. In `configuration.py` wird es zunächst sicher im Dry-run-Modus eingerichtet:
+1 -1
View File
@@ -22,7 +22,7 @@ The safe default is intentionally non-mutating. Installing the UI alone never gr
Install the wheel into NetBox's virtual environment and persist it in `/opt/netbox/local_requirements.txt`: Install the wheel into NetBox's virtual environment and persist it in `/opt/netbox/local_requirements.txt`:
```text ```text
netbox-plugin-store==0.1.1 netbox-plugin-store==0.1.2
``` ```
Add the plugin to `configuration.py`: Add the plugin to `configuration.py`:
+21 -1
View File
@@ -166,7 +166,7 @@ class CatalogPlugin:
) )
def select_release(self, netbox_version: str, requested_version: str = "") -> Release: def select_release(self, netbox_version: str, requested_version: str = "") -> Release:
compatible = [release for release in self.releases if release.supports(netbox_version, self)] compatible = list(self.installable_releases(netbox_version))
if requested_version: if requested_version:
compatible = [release for release in compatible if release.version == requested_version] compatible = [release for release in compatible if release.version == requested_version]
elif self.latest_version: elif self.latest_version:
@@ -177,6 +177,26 @@ class CatalogPlugin:
raise StoreClientError("No compatible release is available for this NetBox version.") raise StoreClientError("No compatible release is available for this NetBox version.")
return max(compatible, key=lambda release: Version(release.version)) return max(compatible, key=lambda release: Version(release.version))
def installable_releases(
self,
netbox_version: str,
*,
require_approval_marker: bool = False,
) -> tuple[Release, ...]:
return tuple(
release
for release in self.releases
if (
release.supports(netbox_version, self)
and release.approved
and release.immutable
and bool(release.download_url)
and bool(release.sha256)
and release.artifact_kind in {"wheel", "source_archive"}
and (not require_approval_marker or bool(release.approved_payload_sha256))
)
)
class StoreClient: class StoreClient:
API_LIMIT = 5 * 1024 * 1024 API_LIMIT = 5 * 1024 * 1024
+12 -8
View File
@@ -34,21 +34,20 @@ class LifecycleConfirmForm(forms.Form):
self.runtime = runtime self.runtime = runtime
self.fields["confirmation"].widget.attrs.update({"autocomplete": "off", "placeholder": plugin.slug}) self.fields["confirmation"].widget.attrs.update({"autocomplete": "off", "placeholder": plugin.slug})
self.fields["dry_run"].initial = runtime.default_dry_run self.fields["dry_run"].initial = runtime.default_dry_run
self.has_installable_release = True
if action in {"install", "update"}: if action in {"install", "update"}:
choices = [ choices = [
(release.version, release.version) (release.version, release.version)
for release in plugin.releases for release in plugin.installable_releases(
if ( runtime.netbox_version,
release.supports(runtime.netbox_version, plugin) require_approval_marker=runtime.execution_mode == "agent",
and release.approved
and release.immutable
and bool(release.sha256)
and (runtime.execution_mode != "agent" or bool(release.approved_payload_sha256))
) )
] ]
choices.sort(reverse=True) choices.sort(reverse=True)
self.fields["version"].choices = choices self.fields["version"].choices = choices
self.fields["version"].required = True self.has_installable_release = bool(choices)
self.fields["version"].required = self.has_installable_release
self.fields["version"].disabled = not self.has_installable_release
self.fields["version"].initial = plugin.latest_version self.fields["version"].initial = plugin.latest_version
else: else:
self.fields.pop("version") self.fields.pop("version")
@@ -64,6 +63,11 @@ class LifecycleConfirmForm(forms.Form):
def clean(self): def clean(self):
cleaned = super().clean() cleaned = super().clean()
if self.action in {"install", "update"} and not self.has_installable_release:
raise forms.ValidationError(
"Keine freigegebene, kompatible Plugin-Version ist verfügbar. "
"Das Source-Artefakt muss zunächst im Store synchronisiert und freigegeben werden."
)
if self.runtime.execution_mode == "dry_run" and not cleaned.get("dry_run", False): if self.runtime.execution_mode == "dry_run" and not cleaned.get("dry_run", False):
self.add_error("dry_run", "Reale Aktionen sind in execution_mode=dry_run deaktiviert.") self.add_error("dry_run", "Reale Aktionen sind in execution_mode=dry_run deaktiviert.")
return cleaned return cleaned
@@ -9,7 +9,7 @@
<p class="text-secondary mb-0">Freigegebene Plugins für diese NetBox-Instanz.</p> <p class="text-secondary mb-0">Freigegebene Plugins für diese NetBox-Instanz.</p>
</div> </div>
{% if runtime %} {% if runtime %}
<span class="badge {% if runtime.execution_mode == 'dry_run' %}bg-yellow text-dark{% else %}bg-green{% endif %}"> <span class="badge {% if runtime.execution_mode == 'dry_run' %}text-bg-warning{% else %}text-bg-success{% endif %}">
Modus: {{ runtime.execution_mode }} Modus: {{ runtime.execution_mode }}
</span> </span>
{% endif %} {% endif %}
@@ -29,14 +29,14 @@
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<h3 class="card-title">{{ card.plugin.name }}</h3> <h3 class="card-title">{{ card.plugin.name }}</h3>
{% if card.plugin.approved %} {% if card.plugin.approved %}
<span class="badge bg-green">Freigegeben</span> <span class="badge text-bg-success">Freigegeben</span>
{% else %} {% else %}
<span class="badge bg-red">Nicht freigegeben</span> <span class="badge text-bg-danger">Nicht freigegeben</span>
{% endif %} {% endif %}
</div> </div>
<p class="text-secondary">{{ card.plugin.summary|default:"Keine Zusammenfassung vorhanden." }}</p> <p class="text-secondary">{{ card.plugin.summary|default:"Keine Zusammenfassung vorhanden." }}</p>
<dl class="row mb-0"> <dl class="row mb-0">
<dt class="col-5">Verfügbar</dt><dd class="col-7">{{ card.plugin.latest_version|default:"" }}</dd> <dt class="col-5">Verfügbar</dt><dd class="col-7">{{ card.plugin.latest_version|default:"Kein Artefakt freigegeben" }}</dd>
<dt class="col-5">Installiert</dt><dd class="col-7">{{ card.installed_version|default:"" }}</dd> <dt class="col-5">Installiert</dt><dd class="col-7">{{ card.installed_version|default:"" }}</dd>
<dt class="col-5">Status</dt> <dt class="col-5">Status</dt>
<dd class="col-7"> <dd class="col-7">
@@ -17,6 +17,11 @@
{% if runtime.execution_mode == 'dry_run' %} {% if runtime.execution_mode == 'dry_run' %}
<div class="alert alert-info">Diese Instanz erlaubt ausschließlich Dry-Runs.</div> <div class="alert alert-info">Diese Instanz erlaubt ausschließlich Dry-Runs.</div>
{% endif %} {% endif %}
{% if not form.has_installable_release %}
<div class="alert alert-warning">
Keine freigegebene, kompatible Version verfügbar. Synchronisiere das Repository im Store-Adminbereich und gib danach das erzeugte Source-Artefakt unter „Release-Artefakte“ frei.
</div>
{% endif %}
<form method="post" action="{% url 'plugins:netbox_plugin_store:lifecycle-action' slug=plugin.slug action=action %}"> <form method="post" action="{% url 'plugins:netbox_plugin_store:lifecycle-action' slug=plugin.slug action=action %}">
{% csrf_token %} {% csrf_token %}
{{ form.non_field_errors }} {{ form.non_field_errors }}
@@ -30,7 +35,7 @@
{% endfor %} {% endfor %}
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<a class="btn btn-outline-secondary" href="{% url 'plugins:netbox_plugin_store:plugin-detail' slug=plugin.slug %}">Abbrechen</a> <a class="btn btn-outline-secondary" href="{% url 'plugins:netbox_plugin_store:plugin-detail' slug=plugin.slug %}">Abbrechen</a>
<button class="btn btn-danger" type="submit">Bestätigen</button> <button class="btn btn-danger" type="submit" {% if not form.has_installable_release %}disabled{% endif %}>Bestätigen</button>
</div> </div>
</form> </form>
</div> </div>
@@ -22,6 +22,12 @@
{% if local_status and local_status.restart_required %} {% if local_status and local_status.restart_required %}
<div class="alert alert-warning">Ein Neustart von NetBox und den Workern ist erforderlich.</div> <div class="alert alert-warning">Ein Neustart von NetBox und den Workern ist erforderlich.</div>
{% endif %} {% endif %}
{% if plugin.approved and not has_installable_release %}
<div class="alert alert-warning">
Für dieses Plugin ist noch kein freigegebenes, mit NetBox {{ runtime.netbox_version }} kompatibles Artefakt verfügbar.
Repositorys ohne eigenen Release werden beim Store-Sync als commitgebundenes Source-Archiv eingelesen; dieses muss anschließend im Store-Adminbereich unter „Release-Artefakte“ freigegeben werden.
</div>
{% endif %}
<div class="row"> <div class="row">
<div class="col-lg-8"> <div class="col-lg-8">
@@ -55,9 +61,9 @@
<div class="list-group-item d-flex justify-content-between"> <div class="list-group-item d-flex justify-content-between">
<span>{{ item.release.version }}</span> <span>{{ item.release.version }}</span>
{% if item.compatible and item.release.approved and item.release.immutable and item.release.sha256 %} {% if item.compatible and item.release.approved and item.release.immutable and item.release.sha256 %}
<span class="badge bg-green">installierbar</span> <span class="badge text-bg-success">installierbar</span>
{% else %} {% else %}
<span class="badge bg-secondary">gesperrt</span> <span class="badge text-bg-secondary">gesperrt</span>
{% endif %} {% endif %}
</div> </div>
{% empty %} {% empty %}
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.1" __version__ = "0.1.2"
+8 -2
View File
@@ -83,12 +83,16 @@ class PluginDetailView(StorePermissionMixin, View):
except StoreClientError as exc: except StoreClientError as exc:
raise Http404(redact_text(exc)) from exc raise Http404(redact_text(exc)) from exc
state = _plugin_state(plugin, set(django_settings.PLUGINS)) state = _plugin_state(plugin, set(django_settings.PLUGINS))
installable_releases = plugin.installable_releases(
runtime.netbox_version,
require_approval_marker=runtime.execution_mode == "agent",
)
actions: list[str] = [] actions: list[str] = []
if not state["installed"]: if not state["installed"]:
if plugin.approved: if plugin.approved and installable_releases:
actions.append("install") actions.append("install")
else: else:
if plugin.approved: if plugin.approved and installable_releases:
actions.append("update") actions.append("update")
if state["enabled"]: if state["enabled"]:
actions.append("disable") actions.append("disable")
@@ -107,7 +111,9 @@ class PluginDetailView(StorePermissionMixin, View):
"state": state, "state": state,
"actions": actions, "actions": actions,
"releases": releases, "releases": releases,
"has_installable_release": bool(installable_releases),
"local_status": local_status, "local_status": local_status,
"runtime": runtime,
}, },
) )
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-plugin-store" name = "netbox-plugin-store"
version = "0.1.1" version = "0.1.2"
description = "A secure NetBox 4.6 plugin lifecycle client for the MrBlake Plugin Store" description = "A secure NetBox 4.6 plugin lifecycle client for the MrBlake Plugin Store"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+21
View File
@@ -58,6 +58,27 @@ class URLPolicyTests(unittest.TestCase):
with self.assertRaises(StoreClientError): with self.assertRaises(StoreClientError):
plugin.select_release("4.6.9") plugin.select_release("4.6.9")
def test_only_verified_supported_artifacts_are_installable(self):
payload, _ = plugin_payload()
payload["releases"].append(
{
"version": "1.3.0",
"download_url": "https://store.example/source.tar.gz",
"sha256": "b" * 64,
"approved": False,
"immutable": True,
"artifact_kind": "source_archive",
"min_netbox_version": "4.6.5",
"max_netbox_version": "4.6.8",
}
)
plugin = CatalogPlugin.from_mapping(payload)
self.assertEqual(
tuple(release.version for release in plugin.installable_releases("4.6.8")),
("1.2.0",),
)
class _Response(io.BytesIO): class _Response(io.BytesIO):
def __init__(self, body): def __init__(self, body):
+1 -1
View File
@@ -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 installierbaren Artefakte gefunden. Prüfe Version und Build-Metadaten des Repositorys.</td></tr><?php endif; ?> <?php if (($releases ?? []) === []): ?><tr><td colspan="5" class="empty-row">Keine installierbaren Artefakte gefunden. Bei Repositorys ohne Release erzeugt eine erneute Synchronisierung automatisch ein commitgebundenes Source-Artefakt, sofern das Python-Paket eine Version definiert.</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>