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
@@ -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`:
```text
netbox-plugin-store==0.1.1
netbox-plugin-store==0.1.2
```
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:
compatible = [release for release in self.releases if release.supports(netbox_version, self)]
compatible = list(self.installable_releases(netbox_version))
if requested_version:
compatible = [release for release in compatible if release.version == requested_version]
elif self.latest_version:
@@ -177,6 +177,26 @@ class CatalogPlugin:
raise StoreClientError("No compatible release is available for this NetBox 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:
API_LIMIT = 5 * 1024 * 1024
+12 -8
View File
@@ -34,21 +34,20 @@ class LifecycleConfirmForm(forms.Form):
self.runtime = runtime
self.fields["confirmation"].widget.attrs.update({"autocomplete": "off", "placeholder": plugin.slug})
self.fields["dry_run"].initial = runtime.default_dry_run
self.has_installable_release = True
if action in {"install", "update"}:
choices = [
(release.version, release.version)
for release in plugin.releases
if (
release.supports(runtime.netbox_version, plugin)
and release.approved
and release.immutable
and bool(release.sha256)
and (runtime.execution_mode != "agent" or bool(release.approved_payload_sha256))
for release in plugin.installable_releases(
runtime.netbox_version,
require_approval_marker=runtime.execution_mode == "agent",
)
]
choices.sort(reverse=True)
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
else:
self.fields.pop("version")
@@ -64,6 +63,11 @@ class LifecycleConfirmForm(forms.Form):
def clean(self):
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):
self.add_error("dry_run", "Reale Aktionen sind in execution_mode=dry_run deaktiviert.")
return cleaned
@@ -9,7 +9,7 @@
<p class="text-secondary mb-0">Freigegebene Plugins für diese NetBox-Instanz.</p>
</div>
{% 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 }}
</span>
{% endif %}
@@ -29,14 +29,14 @@
<div class="d-flex justify-content-between">
<h3 class="card-title">{{ card.plugin.name }}</h3>
{% if card.plugin.approved %}
<span class="badge bg-green">Freigegeben</span>
<span class="badge text-bg-success">Freigegeben</span>
{% else %}
<span class="badge bg-red">Nicht freigegeben</span>
<span class="badge text-bg-danger">Nicht freigegeben</span>
{% endif %}
</div>
<p class="text-secondary">{{ card.plugin.summary|default:"Keine Zusammenfassung vorhanden." }}</p>
<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">Status</dt>
<dd class="col-7">
@@ -17,6 +17,11 @@
{% if runtime.execution_mode == 'dry_run' %}
<div class="alert alert-info">Diese Instanz erlaubt ausschließlich Dry-Runs.</div>
{% 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 %}">
{% csrf_token %}
{{ form.non_field_errors }}
@@ -30,7 +35,7 @@
{% endfor %}
<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>
<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>
</form>
</div>
@@ -22,6 +22,12 @@
{% if local_status and local_status.restart_required %}
<div class="alert alert-warning">Ein Neustart von NetBox und den Workern ist erforderlich.</div>
{% 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="col-lg-8">
@@ -55,9 +61,9 @@
<div class="list-group-item d-flex justify-content-between">
<span>{{ item.release.version }}</span>
{% 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 %}
<span class="badge bg-secondary">gesperrt</span>
<span class="badge text-bg-secondary">gesperrt</span>
{% endif %}
</div>
{% 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:
raise Http404(redact_text(exc)) from exc
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] = []
if not state["installed"]:
if plugin.approved:
if plugin.approved and installable_releases:
actions.append("install")
else:
if plugin.approved:
if plugin.approved and installable_releases:
actions.append("update")
if state["enabled"]:
actions.append("disable")
@@ -107,7 +111,9 @@ class PluginDetailView(StorePermissionMixin, View):
"state": state,
"actions": actions,
"releases": releases,
"has_installable_release": bool(installable_releases),
"local_status": local_status,
"runtime": runtime,
},
)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
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"
readme = "README.md"
requires-python = ">=3.12"
+21
View File
@@ -58,6 +58,27 @@ class URLPolicyTests(unittest.TestCase):
with self.assertRaises(StoreClientError):
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):
def __init__(self, body):