diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx
index ca7ce6ec..b37b1936 100644
--- a/AppImage/components/lxc-app-panel.tsx
+++ b/AppImage/components/lxc-app-panel.tsx
@@ -92,6 +92,9 @@ interface AppConfig {
health_path?: string
logo_url?: string
helper_slug?: string
+ // Set when the application runs inside Docker: registered for its identity
+ // and installed version, updated by the image it comes from.
+ update_via?: string
// Preserved here even though this editor does not execute updates.
// The backend uses full-record replacement, so omitting these when
// editing ports/tracking would silently erase the Updates-tab setup.
@@ -130,6 +133,11 @@ interface AppEntry extends AppConfig {
id: string
state?: AppState
created_at?: string
+ // Resolved server-side from the image a delegated app updates with. It is
+ // not the app's own upstream — the app deliberately has none — so it is
+ // kept in its own field rather than folded into state.latest_version.
+ docker_available_version?: string | null
+ docker_update_available?: boolean | null
}
interface SidecarResponse {
@@ -220,9 +228,25 @@ interface Suggestions {
logo_url?: string | null
category?: string | null
extras?: DetectedApp[]
+ docker_workloads?: DockerWorkload[]
docker_web_links?: DockerWebLinkSuggestion[]
}
+// An application proven to run inside Docker. Shown beneath the Docker
+// detection, never as a registrable app: its update path is the Docker
+// image it comes from, and offering a second one would contradict it.
+interface DockerWorkload {
+ slug: string
+ name: string
+ logo_url?: string | null
+ container_name?: string
+ installed_version?: string | null
+ installed_via?: string | null
+ tracking_suggestion?: TrackingSuggestion | null
+ default_ports?: number[]
+ category?: string | null
+}
+
// Compact catalog entry — one row for every registerable app the
// picker can offer. Fetched once from /api/apps/catalog on panel
// mount, filtered client-side while the user types.
@@ -594,6 +618,13 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
)
// Alias for pre-existing consumers (post-registration chip strip).
const unregisteredDetected = visibleDetected
+ // Applications running inside Docker. They are filtered the same way, and
+ // deliberately NOT nested inside the Docker chip: that chip disappears the
+ // moment Docker is registered, which is exactly when these become
+ // registrable.
+ const visibleWorkloads = (suggestions?.docker_workloads || []).filter(
+ (w) => !registeredSlugs.has(w.slug) && !dismissedSlugs.has(w.slug),
+ )
// Detections the user hid and could restore from the Register-a-
// different-app panel. Not affected by registration state.
const hiddenDetections = detectedList.filter((d) => dismissedSlugs.has(d.slug))
@@ -624,6 +655,16 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
found: true,
text: t("vmLxc.appEditor.newApplicationsDetected", { count: newCount }),
})
+ } else if ((result.docker_workloads || []).length > 0) {
+ // Saying "nothing found" while the panel is listing containerised
+ // applications it just read versions from is the one answer that is
+ // certainly wrong.
+ setDetectionNotice({
+ found: true,
+ text: t("vmLxc.appEditor.dockerDetectedWithWorkloads", {
+ count: (result.docker_workloads || []).length,
+ }),
+ })
} else {
setDetectionNotice({
found: false,
@@ -680,6 +721,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
health_path: existing.health_path || "",
logo_url: existing.logo_url || "",
helper_slug: existing.helper_slug || "",
+ // Preserved explicitly: saving replaces the whole record, so dropping
+ // it here would silently un-delegate the app the first time someone
+ // edits one of its ports.
+ update_via: (existing as { update_via?: string }).update_via || "",
update_command: existing.update_command || "",
hide_no_updater_notice: existing.hide_no_updater_notice === true,
notifications_enabled: existing.notifications_enabled !== false,
@@ -745,6 +790,20 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
docker_image: (t as any).docker_image || "",
tag_regex: t.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)",
}
+ // A containerised workload delegates its update to its image, and
+ // the backend refuses a delegation that also carries an upstream —
+ // so the upstream fields seeded above are cleared, not just ignored.
+ if ((t as { update_via?: string }).update_via === "docker") {
+ seed = {
+ ...seed,
+ update_via: "docker",
+ upstream_type: "",
+ repo: "",
+ upstream_url: "",
+ upstream_json_path: "",
+ docker_image: "",
+ }
+ }
setShowAdvanced(true)
} else {
setShowAdvanced(false)
@@ -1825,6 +1884,22 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
if (!t) patch.tag_regex = ""
setField(patch)
}
+ // A delegated app has no upstream of its own on purpose:
+ // the image it runs on resolves the available version.
+ // Showing an empty "none" dropdown reads as a setting the
+ // user forgot, so say what is happening instead.
+ if (editing.draft.update_via === "docker") {
+ return (
+
)
+ // A containerised application. Same chip language as a native detection,
+ // with the update path spelled out: it is registered for what it is, not
+ // for an updater it does not have.
+ const renderWorkloadChip = (w: DockerWorkload) => (
+
@@ -2561,12 +2704,13 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
hasn't been registered yet is shown as a chip with a
one-click Register button. Filtered against the sidecar's
`helper_slug` field so a registered app never re-appears. */}
- {apps.length > 0 && unregisteredDetected.length > 0 && (
+ {apps.length > 0 && (unregisteredDetected.length > 0 || visibleWorkloads.length > 0) && (
+ {/* "Pending" is a promise that a number is on its way. For a
+ delegated app it never is, by design: the image row carries
+ the available version. Saying nothing here is the honest
+ option — the delegation notice below explains where to look. */}
+ {aw.update_via === "docker" ? (
+ aw.docker_update_available === true && aw.docker_available_version ? (
+
)}
>
diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json
index e4174605..5f53785b 100644
--- a/AppImage/messages/en/common.json
+++ b/AppImage/messages/en/common.json
@@ -1277,6 +1277,7 @@
"osPlusApps": "OS + Apps updates",
"noUpdateMethodTitle": "No update method available",
"noUpdateMethodBody": "No update method has been identified for this application. Add a custom update command.",
+ "updatedWithDockerImage": "Updated with its Docker image.",
"ociImmutableTitle": "OCI image container",
"ociImmutableBody": "OS packages are baked in at image build time and cannot be updated in place. Apply updates manually or reinstall with a newer image.",
"hideNoticeButton": "Hide this notice for this app",
@@ -1513,6 +1514,11 @@
"searchingApplications": "Searching applications…",
"noApplicationsDetected": "No applications were detected.",
"noNewApplicationsDetected": "No new applications were detected.",
+ "dockerDetectedWithWorkloads": "Docker detected with {count} containerised application(s)",
+ "dockerWorkloadsHeading": "Running inside Docker",
+ "runsInsideDocker": "Updated with its Docker image",
+ "upstreamDelegatedTitle": "Available version comes from its Docker image",
+ "upstreamDelegatedHelp": "This application runs in a container, so the available version is whatever its image resolves — no separate upstream check, and one update reported once. Update it from its image in the Updates tab.",
"oneNewApplicationDetected": "One new application was detected.",
"newApplicationsDetected": "{count} new applications were detected.",
"detectionFailed": "Application detection failed",
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py
index 5073395c..515252fa 100644
--- a/AppImage/scripts/flask_server.py
+++ b/AppImage/scripts/flask_server.py
@@ -6693,6 +6693,20 @@ def get_proxmox_vms():
)
if docker_registered and docker_inventory:
vm_data['docker_inventory'] = docker_inventory
+ # An app delegating its updates to Docker has no version
+ # of its own to offer. Resolve its image here, once, so
+ # it can show the number that image already resolved.
+ # This runs even when Docker itself is not registered:
+ # the gate above governs the Docker SECTION, and a user
+ # who registered this application asked precisely to be
+ # told about this application.
+ try:
+ import lxc_apps as _lxc_apps
+ _lxc_apps.annotate_delegated_apps(app_list, docker_inventory)
+ except Exception:
+ # Decoration only: a failure here must never cost
+ # the caller its VM and LXC inventory.
+ pass
# PVE's cluster resources API reports disk=0 for most
# QEMU VMs — it can't see inside the guest filesystem
@@ -13557,13 +13571,20 @@ def api_lxc_updates_detection_set():
@require_auth
def api_vm_apps_get(vmid):
try:
+ import lxc_apps
cached = _vm_cache_get(_vm_apps_cache, vmid, _VM_APPS_TTL)
if cached is not None:
+ # Annotated on the way out, never on the way in: this cache is
+ # invalidated by events, not by time, and the Docker inventory it
+ # reads is built asynchronously. Annotating before storing would
+ # freeze whatever was known at first read — for an app registered
+ # before the first scan, permanently no available version.
+ lxc_apps.annotate_delegated_apps(cached.get('apps') or [], _get_lxc_docker_inventory_map().get(str(vmid)))
return jsonify(cached)
- import lxc_apps
sidecar = lxc_apps.load_sidecar(vmid)
payload = sidecar if sidecar else {'vmid': vmid, 'apps': []}
_vm_cache_put(_vm_apps_cache, vmid, payload)
+ lxc_apps.annotate_delegated_apps(payload.get('apps') or [], _get_lxc_docker_inventory_map().get(str(vmid)))
return jsonify(payload)
except Exception as e:
return jsonify({'error': str(e)}), 500
diff --git a/AppImage/scripts/lxc_apps.py b/AppImage/scripts/lxc_apps.py
index 852148b6..f49f039b 100644
--- a/AppImage/scripts/lxc_apps.py
+++ b/AppImage/scripts/lxc_apps.py
@@ -184,6 +184,22 @@ _DOCKER_MANIFEST_ACCEPT = ", ".join((
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.docker.distribution.manifest.v2+json",
))
+# Reading the remote image config is content-addressed: every document is
+# requested BY DIGEST and verified against it, so the answer cannot be
+# swapped for another image. A digest never changes content, so the cache
+# has no TTL — only a bound.
+_DOCKER_REMOTE_CONFIG_MAX_BYTES = 1 << 20
+_DOCKER_REMOTE_CONFIG_CACHE_MAX = 500
+_DOCKER_INDEX_MEDIA_TYPES = {
+ "application/vnd.oci.image.index.v1+json",
+ "application/vnd.docker.distribution.manifest.list.v2+json",
+}
+_DOCKER_IMAGE_CONFIG_MEDIA_TYPES = {
+ "application/vnd.oci.image.config.v1+json",
+ "application/vnd.docker.container.image.v1+json",
+}
+_docker_remote_config_lock = threading.RLock()
+_docker_remote_config_cache: dict[tuple, dict] = {}
_docker_inventory_lock = threading.RLock()
_docker_inventory_cache: dict[str, dict] = {}
@@ -1034,6 +1050,26 @@ def validate_config(payload: dict) -> tuple[bool, Any]:
return _err("helper_slug must be a lowercase slug (letters/digits/._-)")
conf["helper_slug"] = hs
+ # Optional update delegation. An application running inside Docker is a
+ # real application — it has a name, a logo, links and an installed
+ # version — but it is not updated on its own: the update is a pull and a
+ # recreate of the image it comes from, which the Docker inventory already
+ # knows how to do. Marking it here keeps ONE update path for one fact:
+ # this app reports its identity and its installed version, and the image
+ # row reports whether there is a new one. Upstream fields are rejected
+ # rather than stripped, so nobody registers an app that silently checks
+ # GitHub behind a delegation that says it will not.
+ uv = (payload.get("update_via") or "").strip().lower()
+ if uv:
+ if uv != "docker":
+ return _err("update_via only accepts 'docker'")
+ if method not in ("docker_label", "docker_exec"):
+ return _err("update_via=docker requires a docker_label or docker_exec detector")
+ for field in ("repo", "upstream_type", "upstream_url", "docker_image"):
+ if (payload.get(field) or "").strip():
+ return _err(f"update_via=docker cannot be combined with {field}")
+ conf["update_via"] = uv
+
# Optional user-defined update command. Freeform bash that runs
# under `pct exec vmid -- sh -c "$command"` when the user hits
# "Apply {app} update" from the Updates tab. This is deliberately
@@ -1653,6 +1689,22 @@ def _normalise_docker_display_version(value: Any) -> Optional[str]:
return match.group(1) if match else None
+def _docker_version_from_labels(
+ labels: dict,
+ keys: tuple = ("Version", "version", "org.opencontainers.image.version"),
+) -> tuple[Optional[str], Optional[str]]:
+ """Resolve a version from image labels, in the caller's order of trust.
+
+ Shared by the local image inspect and by the remote image config so both
+ sides of the comparison read the same labels through the same filter.
+ """
+ for key in keys:
+ version = _normalise_docker_display_version((labels or {}).get(key))
+ if version:
+ return version, f"image_label:{key}"
+ return None, None
+
+
def _docker_version_from_image_inspect(parsed: dict, inspected: dict) -> tuple[Optional[str], Optional[str]]:
"""Resolve an installed image version from local, immutable evidence."""
direct_tag = _normalise_docker_display_version(parsed.get("tag"))
@@ -1662,10 +1714,9 @@ def _docker_version_from_image_inspect(parsed: dict, inspected: dict) -> tuple[O
labels = ((inspected.get("Config") or {}).get("Labels") or {})
# Application-specific labels take precedence over OCI labels because a
# few publishers put the base distribution version in the latter.
- for key in ("Version", "version"):
- version = _normalise_docker_display_version(labels.get(key))
- if version:
- return version, f"image_label:{key}"
+ version, source = _docker_version_from_labels(labels, ("Version", "version"))
+ if version:
+ return version, source
# A moving tag often shares an image ID with an explicit release tag
# already present locally (for example frigate:stable + frigate:0.17.2).
@@ -1686,10 +1737,7 @@ def _docker_version_from_image_inspect(parsed: dict, inspected: dict) -> tuple[O
alternate_versions.sort(key=_docker_tag_semver_key, reverse=True)
return alternate_versions[0], "local_equivalent_tag"
- version = _normalise_docker_display_version(labels.get("org.opencontainers.image.version"))
- if version:
- return version, "image_label:org.opencontainers.image.version"
- return None, None
+ return _docker_version_from_labels(labels, ("org.opencontainers.image.version",))
def _docker_hub_version_for_digest(records: list[dict], digest: Optional[str]) -> Optional[str]:
@@ -1835,45 +1883,124 @@ def _parse_bearer_challenge(value: str) -> Optional[dict]:
return params
-def _registry_digest_request(url: str, headers: dict) -> tuple[Optional[str], Optional[str]]:
- req = urllib.request.Request(url, headers=headers, method="HEAD")
+class _DockerNoRedirect(urllib.request.HTTPRedirectHandler):
+ """Surface 3xx instead of following it.
+
+ urllib re-sends ``Authorization`` to the redirect target; registries hand
+ blobs off to signed-URL storage that rejects a second auth mechanism, and
+ the official Docker client drops the header on a host change. Following
+ the hop by hand is the only way to drop it.
+ """
+
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ return None
+
+
+_docker_no_redirect_opener = urllib.request.build_opener(_DockerNoRedirect)
+
+
+def _registry_bearer_token(challenge: dict) -> tuple[Optional[str], Optional[str]]:
+ """Exchange a parsed ``WWW-Authenticate`` challenge for a pull token."""
+ query = {
+ key: challenge[key]
+ for key in ("service", "scope") if challenge.get(key)
+ }
+ token_url = challenge["realm"]
+ if query:
+ token_url += ("&" if "?" in token_url else "?") + urllib.parse.urlencode(query)
+ token_req = urllib.request.Request(
+ token_url,
+ headers={"User-Agent": "ProxMenux-Monitor", "Accept": "application/json"},
+ )
try:
- with urllib.request.urlopen(req, timeout=_DOCKER_REGISTRY_TIMEOUT_SEC) as response:
- return response.headers.get("Docker-Content-Digest"), None
+ with urllib.request.urlopen(token_req, timeout=_DOCKER_REGISTRY_TIMEOUT_SEC) as response:
+ token_payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
- if exc.code != 401:
- return None, f"registry HTTP {exc.code}"
- challenge = _parse_bearer_challenge(exc.headers.get("WWW-Authenticate", ""))
- if not challenge:
- return None, "registry authentication required"
- query = {
- key: challenge[key]
- for key in ("service", "scope") if challenge.get(key)
- }
- token_url = challenge["realm"]
- if query:
- token_url += ("&" if "?" in token_url else "?") + urllib.parse.urlencode(query)
- token_req = urllib.request.Request(
- token_url,
- headers={"User-Agent": "ProxMenux-Monitor", "Accept": "application/json"},
- )
- try:
- with urllib.request.urlopen(token_req, timeout=_DOCKER_REGISTRY_TIMEOUT_SEC) as response:
- token_payload = json.loads(response.read().decode("utf-8"))
- token = token_payload.get("token") or token_payload.get("access_token")
- if not token:
- return None, "registry token response was empty"
- auth_headers = dict(headers)
- auth_headers["Authorization"] = f"Bearer {token}"
- auth_req = urllib.request.Request(url, headers=auth_headers, method="HEAD")
- with urllib.request.urlopen(auth_req, timeout=_DOCKER_REGISTRY_TIMEOUT_SEC) as response:
- return response.headers.get("Docker-Content-Digest"), None
- except urllib.error.HTTPError as auth_exc:
- return None, f"registry HTTP {auth_exc.code}"
- except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as auth_exc:
- return None, f"registry network error: {auth_exc}"
- except (urllib.error.URLError, TimeoutError, OSError) as exc:
+ return None, f"registry HTTP {exc.code}"
+ except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
return None, f"registry network error: {exc}"
+ token = token_payload.get("token") or token_payload.get("access_token")
+ if not token:
+ return None, "registry token response was empty"
+ return token, None
+
+
+def _registry_open(url: str, headers: dict, method: str, max_bytes: int):
+ """One registry request. Returns (headers, body, status, location, error)."""
+ req = urllib.request.Request(url, headers=headers, method=method)
+ try:
+ with _docker_no_redirect_opener.open(req, timeout=_DOCKER_REGISTRY_TIMEOUT_SEC) as response:
+ body = None
+ if max_bytes > 0:
+ body = response.read(max_bytes + 1)
+ if len(body) > max_bytes:
+ return None, None, None, None, "registry response exceeded the size limit"
+ return response.headers, body, 200, None, None
+ except urllib.error.HTTPError as exc:
+ if exc.code in (301, 302, 303, 307, 308):
+ return exc.headers, None, exc.code, exc.headers.get("Location"), None
+ if exc.code == 401:
+ return exc.headers, None, 401, None, None
+ return None, None, exc.code, None, f"registry HTTP {exc.code}"
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
+ return None, None, None, None, f"registry network error: {exc}"
+
+
+def _registry_request(url: str, headers: dict, method: str = "HEAD",
+ token: Optional[str] = None, max_bytes: int = 0,
+ follow_redirect: bool = True):
+ """Registry request resolving a Bearer challenge once.
+
+ Returns (headers, body, token, error). The token is returned so a caller
+ walking manifest -> manifest -> blob pays for a single challenge.
+ """
+ attempt_headers = dict(headers)
+ if token:
+ attempt_headers["Authorization"] = f"Bearer {token}"
+ response_headers, body, status, location, error = _registry_open(
+ url, attempt_headers, method, max_bytes,
+ )
+ if error:
+ return None, None, token, error
+ if status == 401:
+ challenge = _parse_bearer_challenge((response_headers or {}).get("WWW-Authenticate", ""))
+ if not challenge:
+ return None, None, token, "registry authentication required"
+ token, token_error = _registry_bearer_token(challenge)
+ if token_error:
+ return None, None, None, token_error
+ attempt_headers["Authorization"] = f"Bearer {token}"
+ response_headers, body, status, location, error = _registry_open(
+ url, attempt_headers, method, max_bytes,
+ )
+ if error:
+ return None, None, token, error
+ if status == 401:
+ return None, None, token, "registry HTTP 401"
+ if location:
+ if not follow_redirect:
+ return None, None, token, "registry redirected unexpectedly"
+ if not location.startswith("https://"):
+ return None, None, token, "registry redirect was not https"
+ cdn_headers = {
+ key: value for key, value in headers.items()
+ if key.lower() != "authorization"
+ }
+ response_headers, body, status, location, error = _registry_open(
+ location, cdn_headers, method, max_bytes,
+ )
+ if error:
+ return None, None, token, error
+ if location:
+ return None, None, token, "registry redirected more than once"
+ return response_headers, body, token, None
+
+
+def _registry_digest_request(url: str, headers: dict) -> tuple[Optional[str], Optional[str]]:
+ response_headers, _body, _token, error = _registry_request(url, headers, method="HEAD")
+ if error:
+ return None, error
+ return (response_headers or {}).get("Docker-Content-Digest"), None
def _fetch_registry_manifest_digest(parsed: dict) -> tuple[Optional[str], Optional[str]]:
@@ -1886,6 +2013,166 @@ def _fetch_registry_manifest_digest(parsed: dict) -> tuple[Optional[str], Option
})
+def _verify_content_digest(body: bytes, digest: str) -> bool:
+ """Every registry document is named by its own sha256; check it."""
+ algorithm, _, want = str(digest or "").partition(":")
+ if algorithm != "sha256" or not want or body is None:
+ return False
+ return hashlib.sha256(body).hexdigest() == want
+
+
+def _select_platform_manifest(index: dict, platform: dict) -> Optional[str]:
+ """Return the manifest digest matching the locally installed platform.
+
+ Multi-arch indexes also carry attestation manifests, which advertise
+ ``unknown/unknown``: their config is an SLSA provenance document, not an
+ image. Picking "the first entry" would read that instead. A missing match
+ yields None rather than a fallback — if the index has no build for this
+ host, a pull would fail and there is no version to report.
+ """
+ def _norm(entry: dict) -> tuple:
+ os_name = str(entry.get("os") or "").lower()
+ architecture = str(entry.get("architecture") or "").lower()
+ variant = str(entry.get("variant") or "").lower()
+ # arm64/v8 and bare arm64 name the same build.
+ if architecture == "arm64" and variant == "v8":
+ variant = ""
+ return os_name, architecture, variant
+
+ want = _norm(platform or {})
+ if not want[0] or not want[1]:
+ return None
+ for entry in (index or {}).get("manifests") or []:
+ if not isinstance(entry, dict):
+ continue
+ if (entry.get("annotations") or {}).get("vnd.docker.reference.type"):
+ continue
+ candidate = _norm(entry.get("platform") or {})
+ if candidate[0] in ("", "unknown") or candidate[1] in ("", "unknown"):
+ continue
+ if candidate != want:
+ continue
+ digest = str(entry.get("digest") or "")
+ if digest.startswith("sha256:"):
+ return digest
+ return None
+
+
+def _registry_get_document(base: str, digest: str, headers: dict, token: Optional[str],
+ ) -> tuple[Optional[dict], Optional[str], Optional[str]]:
+ """GET a registry document by digest and verify it. Returns (json, token, error)."""
+ _response, body, token, error = _registry_request(
+ f"{base}/manifests/{urllib.parse.quote(digest, safe=':')}", headers,
+ method="GET", token=token, max_bytes=_DOCKER_REMOTE_CONFIG_MAX_BYTES,
+ )
+ if error:
+ return None, token, error
+ if not _verify_content_digest(body, digest):
+ return None, token, "remote manifest digest mismatch"
+ try:
+ return json.loads(body.decode("utf-8")), token, None
+ except (UnicodeDecodeError, json.JSONDecodeError):
+ return None, token, "remote manifest was not valid JSON"
+
+
+def _remote_image_config_labels(parsed: dict, remote_digest: str, platform: dict,
+ ) -> tuple[Optional[dict], Optional[str]]:
+ base = f"https://{parsed.get('api_host')}/v2/{urllib.parse.quote(parsed.get('repository') or '', safe='/')}"
+ headers = {"User-Agent": "ProxMenux-Monitor", "Accept": _DOCKER_MANIFEST_ACCEPT}
+
+ manifest, token, error = _registry_get_document(base, remote_digest, headers, None)
+ if error:
+ return None, error
+ media_type = str(manifest.get("mediaType") or "")
+ if media_type in _DOCKER_INDEX_MEDIA_TYPES or "manifests" in manifest:
+ platform_digest = _select_platform_manifest(manifest, platform)
+ if not platform_digest:
+ return None, "remote_platform_missing"
+ manifest, token, error = _registry_get_document(base, platform_digest, headers, token)
+ if error:
+ return None, error
+ config = manifest.get("config") or {}
+ config_digest = str(config.get("digest") or "")
+ if (str(config.get("mediaType") or "") not in _DOCKER_IMAGE_CONFIG_MEDIA_TYPES
+ or not config_digest.startswith("sha256:")):
+ # Schema v1 manifests and non-image OCI artifacts (Helm charts,
+ # signatures) have no image config to read.
+ return None, "remote_unsupported_manifest"
+ _response, body, _token, error = _registry_request(
+ f"{base}/blobs/{urllib.parse.quote(config_digest, safe=':')}",
+ {"User-Agent": "ProxMenux-Monitor", "Accept": "*/*"},
+ method="GET", token=token, max_bytes=_DOCKER_REMOTE_CONFIG_MAX_BYTES,
+ )
+ if error:
+ return None, error
+ if not _verify_content_digest(body, config_digest):
+ return None, "remote config digest mismatch"
+ try:
+ payload = json.loads(body.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError):
+ return None, "remote config was not valid JSON"
+ return ((payload.get("config") or {}).get("Labels") or {}), None
+
+
+def _fetch_remote_image_config_labels(parsed: dict, remote_digest: str, platform: dict,
+ ) -> tuple[Optional[dict], Optional[str]]:
+ """Cached read of the labels carried by the image a pull would install."""
+ cache_key = (
+ str(parsed.get("api_host") or ""),
+ str(parsed.get("repository") or ""),
+ str(remote_digest or ""),
+ str((platform or {}).get("architecture") or ""),
+ )
+ with _docker_remote_config_lock:
+ cached = _docker_remote_config_cache.get(cache_key)
+ if cached is not None:
+ return cached.get("labels"), cached.get("error")
+ labels, error = _remote_image_config_labels(parsed, remote_digest, platform)
+ with _docker_remote_config_lock:
+ if len(_docker_remote_config_cache) >= _DOCKER_REMOTE_CONFIG_CACHE_MAX:
+ _docker_remote_config_cache.clear()
+ _docker_remote_config_cache[cache_key] = {"labels": labels, "error": error}
+ return labels, error
+
+
+_DOCKER_AVAILABLE_VERSION_REASONS = {
+ "remote_platform_missing", "remote_unsupported_manifest", "remote_no_labels",
+}
+
+
+def _docker_available_version_from_registry(item: dict) -> tuple[Optional[str], str]:
+ """Version of the image that pulling this same tag would install.
+
+ This is deliberately not "the latest upstream release": the update action
+ this project generates re-pulls the SAME tag, so the honest number is the
+ one the registry is serving for it right now. Anything that cannot be
+ established returns no version and a reason, keeping the existing contract
+ that a new digest is reported without claiming a version number.
+ """
+ remote_digest = item.get("remote_digest")
+ if not remote_digest:
+ return None, "remote_fetch_error"
+ labels, error = _fetch_remote_image_config_labels(
+ item, remote_digest, item.get("platform") or {},
+ )
+ if error:
+ return None, error if error in _DOCKER_AVAILABLE_VERSION_REASONS else "remote_fetch_error"
+ version, source = _docker_version_from_labels(labels or {})
+ if not version:
+ return None, "remote_no_labels"
+ installed_source = str(item.get("installed_version_source") or "")
+ if installed_source.startswith("image_label:") and installed_source != source:
+ # Comparing one publisher's label against a different one invents a
+ # difference that is not there.
+ return None, "version_source_mismatch"
+ installed = item.get("installed_version")
+ if installed and compare(installed, version) is not True:
+ # Same version rebuilt, retagged, or an unusable pair: the digest
+ # already says there is a new image; no number is the honest answer.
+ return None, "version_not_comparable"
+ return version, f"remote_{source}"
+
+
def _parse_compose_depends_on(value: Any) -> list[str]:
"""Return service names from Compose's ``depends_on`` label.
@@ -2075,6 +2362,64 @@ def _build_docker_update_units(images: list[dict]) -> list[dict]:
))
+def resolve_docker_image_for_app(app: dict, inventory: dict) -> dict:
+ """Find the Docker image that owns a delegated app.
+
+ The bridge is the container the app declares, not its name or its image:
+ Immich's compose service and image are both "immich-server" while the
+ application is "immich", and matching on names would also let a container
+ that merely shares a word with a catalog entry claim it. The inventory is
+ backed by ``docker ps -a``, so a stopped container still resolves.
+
+ Returns ``{image_reference, error}`` with ``error`` naming the reason when
+ it cannot be resolved, so the UI can say why instead of silently showing
+ nothing.
+ """
+ container = str(app.get("container_name") or "").strip()
+ if not container:
+ return {"image_reference": None, "error": "no_container_declared"}
+ if not inventory or not inventory.get("available"):
+ return {"image_reference": None, "error": "inventory_unavailable"}
+ for image in inventory.get("images") or []:
+ if container in (image.get("used_by") or []):
+ return {"image_reference": image.get("reference"), "error": None}
+ return {"image_reference": None, "error": "container_not_in_inventory"}
+
+
+def annotate_delegated_apps(apps: list, docker_inventory: dict) -> None:
+ """Attach the image-resolved version to apps that delegate to Docker.
+
+ Decoration only, and deliberately fail-safe: this adds a version number
+ to an app card and must never be able to cost the caller its response.
+ The fields are namespaced so they cannot feed the CT badge or the update
+ counters, where the image already contributes.
+ """
+ if not apps or not docker_inventory:
+ return
+ try:
+ for app in apps:
+ if not isinstance(app, dict) or app.get('update_via') != 'docker':
+ continue
+ # Cleared before every resolution, not only written on success:
+ # these dicts live in caches invalidated by events rather than
+ # time, so a container renamed or removed would otherwise keep
+ # showing the version of an image it no longer runs.
+ app['docker_available_version'] = None
+ app['docker_update_available'] = None
+ link = resolve_docker_image_for_app(app, docker_inventory)
+ reference = link.get('image_reference')
+ if not reference:
+ continue
+ for image in docker_inventory.get('images') or []:
+ if image.get('reference') != reference:
+ continue
+ app['docker_available_version'] = image.get('available_version')
+ app['docker_update_available'] = image.get('update_available')
+ break
+ except Exception:
+ pass
+
+
def _aggregate_docker_compose_projects(images: list[dict]) -> list[dict]:
"""Merge per-image Compose targets into one safe action per project.
@@ -2321,7 +2666,16 @@ def _docker_inventory_from_ct(vmid) -> dict:
"logo_url": display_meta.get("logo_url"),
"installed_version": installed_version,
"installed_version_source": installed_version_source,
+ # The platform of the image actually installed here. A multi-arch
+ # index must be resolved to this exact build before its config can
+ # be read; anything else would describe a different binary.
+ "platform": {
+ "os": str(inspected_image.get("Os") or ""),
+ "architecture": str(inspected_image.get("Architecture") or ""),
+ "variant": str(inspected_image.get("Variant") or ""),
+ },
"available_version": None,
+ "available_version_source": None,
"update_available": None,
"error": None,
})
@@ -2372,6 +2726,30 @@ def _docker_inventory_from_ct(vmid) -> dict:
available_version = _docker_hub_version_for_digest(records, item.get("remote_digest"))
if available_version and available_version != item.get("installed_version"):
item["available_version"] = available_version
+ item["available_version_source"] = "docker_hub_digest_tag"
+
+ # The shortcut above only covers docker.io, and only when a version
+ # tag happens to share the digest. Everywhere else — ghcr.io, lscr.io,
+ # quay.io — an available update had no version number at all. The
+ # image a pull would install carries its own version label, so read it
+ # from the registry by digest, over the same protocol and Bearer
+ # challenge the digest comparison already uses. Hub keeps priority on
+ # docker.io because its tag API is not a pull and does not consume the
+ # anonymous pull-rate budget, and because official images carry no
+ # labels at all.
+ pending = [
+ item for item in images
+ if item.get("update_available") is True
+ and not item.get("available_version")
+ and item.get("remote_digest")
+ ]
+ if pending:
+ with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(pending))) as pool:
+ resolved = list(pool.map(_docker_available_version_from_registry, pending))
+ for item, (available_version, source) in zip(pending, resolved):
+ item["available_version_source"] = source
+ if available_version and available_version != item.get("installed_version"):
+ item["available_version"] = available_version
return {
"vmid": int(vmid),
@@ -3049,7 +3427,12 @@ def partition_scheduled_release_targets(
eligible_apps: list[dict] = []
for app in apps or []:
app_id = str(app.get("id") or "").strip()
- if not app_id or app.get("managed_oci_app_id") or app.get("helper_slug") == "docker":
+ # A delegated app has no updater of its own and never resolves a
+ # release date, so including it would hold the whole schedule back
+ # waiting for a date that will never arrive.
+ if (not app_id or app.get("managed_oci_app_id")
+ or app.get("helper_slug") == "docker"
+ or app.get("update_via") == "docker"):
continue
if not select_all_apps and app_id not in selected_app_ids:
continue
@@ -3229,6 +3612,11 @@ def _fire_update_notification(vmid, app: dict) -> None:
return
if app.get("helper_slug") == "docker":
return
+ # Delegated apps are announced by their Docker image's own event; a
+ # second one for the same release would land in a different event type
+ # and therefore escape deduplication.
+ if app.get("update_via") == "docker":
+ return
try:
from notification_manager import notification_manager
import socket
@@ -3296,12 +3684,19 @@ def _docker_stack_notification_payload(
lines.append(f'• Docker Engine: {installed} → {latest}')
for image in pending_images[:12]:
reference = image.get('reference') or 'Docker image'
+ # Lead with the application when the catalog resolved one: the alert
+ # is read on a phone, where "vaultwarden/server:latest" is a worse
+ # answer to "what needs updating" than "Vaultwarden". The reference
+ # stays, because it is what the user acts on. The deduplication
+ # signature above keeps using the reference alone.
+ display_name = str(image.get('display_name') or '').strip()
+ label = f'{display_name} ({reference})' if display_name and display_name != reference else reference
installed = image.get('installed_version')
available = image.get('available_version')
if installed and available and installed != available:
- lines.append(f'• {reference}: {installed} → {available}')
+ lines.append(f'• {label}: {installed} → {available}')
else:
- lines.append(f'• {reference}: new registry digest')
+ lines.append(f'• {label}: new registry digest')
if len(pending_images) > 12:
lines.append(f'• +{len(pending_images) - 12} additional image update(s)')
return {
@@ -3383,6 +3778,14 @@ def _detect_with_alt_healing(vmid, app: dict) -> tuple:
slug = app.get("helper_slug")
hint = (_fetch_tracking_hints() or {}).get(slug) or {}
+ # An app whose updates are delegated to its Docker image must keep its
+ # docker detector. Healing it onto a leftover /root/. marker would
+ # silently turn it into a native app that then checks its own upstream —
+ # the exact duplication the delegation exists to prevent.
+ if app.get("update_via") == "docker":
+ version, error = detect_installed_version(vmid, app)
+ return version, error, False
+
# A modern Community Scripts marker (/root/.) is a useful
# fallback, but it is not a live process probe. It can stay behind when
# an operator upgrades an application outside the helper script. When a
@@ -3643,6 +4046,8 @@ def _summarise_app(app: dict) -> dict:
"id": app.get("id"),
"name": app.get("name"),
"installed_via": app.get("installed_via"),
+ "update_via": app.get("update_via"),
+ "container_name": app.get("container_name"),
"ports": app.get("ports") or [],
# Keep the application-level logo in the compact /api/vms
# projection. Consumers can prefer a per-link logo and fall
@@ -4221,6 +4626,24 @@ def _probe_listening_ports(vmid) -> list[int]:
return result
+def _docker_container_slug_index() -> dict:
+ """Map a declared ``container_name`` to the catalog slug that declares it.
+
+ Only detectors carrying curated runtime evidence appear here, which keeps
+ the mapping conservative: a container merely called "postgres" is not
+ claimed by an app, because no verified detector says it is.
+ """
+ index: dict = {}
+ for slug, hint in (_fetch_tracking_hints() or {}).items():
+ for detector in _iter_hint_detectors(hint):
+ if detector.get("installed_via") not in ("docker_label", "docker_exec"):
+ continue
+ name = str(detector.get("container_name") or "").strip().lower()
+ if name:
+ index.setdefault(name, slug)
+ return index
+
+
def _docker_service_catalog_meta(service: str, container: str, image: str) -> dict:
"""Best-effort display metadata for a Docker workload.
@@ -4231,6 +4654,13 @@ def _docker_service_catalog_meta(service: str, container: str, image: str) -> di
image_base = image.split("@", 1)[0].rsplit("/", 1)[-1].split(":", 1)[0]
raw_candidates = [service, container, image_base]
candidates: list[str] = []
+ # A curated docker detector names the container it runs in, which is the
+ # only reliable bridge from a container to its catalog entry: Immich's
+ # service is "immich-server" and its image is "immich-server", but the
+ # application is "immich". Heuristic name matching never gets there.
+ declared = _docker_container_slug_index().get(str(container or "").strip().lower())
+ if declared:
+ candidates.append(declared)
for raw in raw_candidates:
candidate = re.sub(r"[^a-z0-9._-]+", "-", str(raw or "").strip().lower()).strip("-._")
if candidate and candidate not in candidates:
@@ -4546,6 +4976,12 @@ def get_suggestions(vmid, force: bool = False) -> dict:
)
docker_web_links = _probe_docker_web_links(vmid) if docker_host_detected else []
extras: list = []
+ # Applications proven to run inside Docker are not registrable apps — see
+ # the skip below — but discarding them entirely made the panel answer "no
+ # applications detected" for a container whose version had just been read
+ # successfully. They are reported separately so the UI can show what is in
+ # there without offering a second, competing update path.
+ docker_workloads: list = []
for det_slug in sorted(detected_map):
if slug and det_slug == slug:
continue
@@ -4570,6 +5006,45 @@ def get_suggestions(vmid, force: bool = False) -> dict:
for d in matched_detectors
)
if all_docker:
+ workload_name = det_catalog.get("name") or det_hint.get("name") or det_slug
+ workload_logo = ""
+ for candidate in (det_hint.get("logo"), det_catalog.get("logo")):
+ if isinstance(candidate, str) and candidate.startswith(("http://", "https://")):
+ workload_logo = candidate
+ break
+ workload_container = (working or {}).get("container_name") or ""
+ # The detector that actually matched, minus everything that
+ # would make this app check an upstream of its own: the image
+ # it runs on is what says whether there is a new version.
+ workload_tracking = {
+ key: value for key, value in (working or {}).items()
+ if key in _DETECTOR_FIELDS or key == "installed_via"
+ }
+ workload_tracking["installed_regex"] = (
+ (working or {}).get("installed_regex")
+ or det_hint.get("installed_regex")
+ or det_hint.get("tag_regex")
+ or ""
+ )
+ workload_tracking["detector_verified"] = True
+ workload_tracking["detector_source"] = "runtime_probe"
+ workload_tracking["detected_version"] = (working or {}).get("detected_version") or ""
+ workload_tracking["update_via"] = "docker"
+ docker_workloads.append({
+ "slug": det_slug,
+ "name": workload_name,
+ "logo_url": workload_logo or None,
+ "container_name": workload_container,
+ "installed_version": (working or {}).get("detected_version") or None,
+ "installed_via": (working or {}).get("installed_via") or None,
+ "tracking_suggestion": workload_tracking,
+ "default_ports": sorted({
+ link["host_port"] for link in docker_web_links
+ if link.get("container_name") == workload_container
+ and isinstance(link.get("host_port"), int)
+ }),
+ "category": suggest_category_for(det_slug),
+ })
continue
det_tracking = dict(det_hint)
if working:
@@ -4642,5 +5117,6 @@ def get_suggestions(vmid, force: bool = False) -> dict:
# get_catalog_entry so the Register button pre-selects it.
"category": suggest_category_for(slug),
"extras": extras,
+ "docker_workloads": sorted(docker_workloads, key=lambda item: item["name"].lower()),
"docker_web_links": docker_web_links,
}
diff --git a/AppImage/scripts/tests/test_docker_delegated_apps.py b/AppImage/scripts/tests/test_docker_delegated_apps.py
new file mode 100644
index 00000000..a4d6022a
--- /dev/null
+++ b/AppImage/scripts/tests/test_docker_delegated_apps.py
@@ -0,0 +1,179 @@
+"""Applications that run inside Docker: registrable, but updated by their image.
+
+Such an app is a real application — name, logo, links, installed version — and
+is registered like any other. What it does not get is an update path of its
+own: the image it comes from already has one, and two would mean two badges,
+two notifications and two buttons for a single release.
+"""
+
+import sys
+import unittest
+from pathlib import Path
+
+SCRIPTS_DIR = Path(__file__).resolve().parents[1]
+if str(SCRIPTS_DIR) not in sys.path:
+ sys.path.insert(0, str(SCRIPTS_DIR))
+
+import lxc_apps
+
+
+class DelegationValidationTests(unittest.TestCase):
+ BASE = {
+ "name": "Vaultwarden",
+ "installed_via": "docker_exec",
+ "container_name": "vaultwarden",
+ "binary_path": "/vaultwarden",
+ "binary_args": ["--version"],
+ "installed_regex": r"(?im)^Vaultwarden\s+v?(\d+\.\d+\.\d+)",
+ }
+
+ def test_delegation_is_accepted_with_a_docker_detector(self):
+ ok, conf = lxc_apps.validate_config({**self.BASE, "update_via": "docker"})
+ self.assertTrue(ok, conf)
+ self.assertEqual(conf["update_via"], "docker")
+ self.assertEqual(conf["container_name"], "vaultwarden")
+
+ def test_delegation_requires_a_docker_detector(self):
+ ok, error = lxc_apps.validate_config({
+ "name": "Vaultwarden", "installed_via": "dpkg",
+ "package": "vaultwarden", "update_via": "docker",
+ })
+ self.assertFalse(ok)
+ self.assertIn("docker_label or docker_exec", str(error))
+
+ def test_delegation_rejects_an_upstream_instead_of_stripping_it(self):
+ """Silently dropping it would register an app that checks GitHub
+ behind a delegation promising it will not."""
+ ok, error = lxc_apps.validate_config({
+ **self.BASE, "update_via": "docker",
+ "repo": "dani-garcia/vaultwarden", "github_source": "releases",
+ })
+ self.assertFalse(ok)
+ self.assertIn("repo", str(error))
+
+ def test_an_undelegated_docker_app_still_works(self):
+ """Someone who wired their own updater keeps it."""
+ ok, conf = lxc_apps.validate_config({
+ **self.BASE, "repo": "dani-garcia/vaultwarden",
+ "github_source": "releases", "tag_regex": r"v?(\d+\.\d+\.\d+)",
+ })
+ self.assertTrue(ok, conf)
+ self.assertNotIn("update_via", conf)
+ self.assertEqual(conf["repo"], "dani-garcia/vaultwarden")
+
+
+class UnitResolutionTests(unittest.TestCase):
+ INVENTORY = {
+ "available": True,
+ "containers": [
+ {"name": "vaultwarden", "compose": None},
+ {"name": "immich_server", "compose": {"project": "immich", "service": "immich-server"}},
+ {"name": "immich_redis", "compose": {"project": "immich", "service": "redis"}},
+ ],
+ "images": [
+ {"reference": "vaultwarden/server:latest", "used_by": ["vaultwarden"]},
+ {"reference": "ghcr.io/immich-app/immich-server:release", "used_by": ["immich_server"]},
+ ],
+ "update_units": [
+ {"id": "standalone:vaultwarden", "kind": "standalone",
+ "standalone_containers": ["vaultwarden"]},
+ {"id": "compose:immich", "kind": "compose", "project": "immich",
+ "services": ["immich-server", "redis", "postgres"]},
+ ],
+ }
+
+ def test_standalone_container_resolves_to_its_image(self):
+ got = lxc_apps.resolve_docker_image_for_app(
+ {"container_name": "vaultwarden"}, self.INVENTORY)
+ self.assertEqual(got["image_reference"], "vaultwarden/server:latest")
+ self.assertIsNone(got["error"])
+
+ def test_compose_service_resolves_to_its_own_image(self):
+ """Immich is four containers; only the one the app declares counts."""
+ got = lxc_apps.resolve_docker_image_for_app(
+ {"container_name": "immich_server"}, self.INVENTORY)
+ self.assertEqual(got["image_reference"], "ghcr.io/immich-app/immich-server:release")
+
+ def test_renamed_container_says_so(self):
+ got = lxc_apps.resolve_docker_image_for_app(
+ {"container_name": "vaultwarden-old"}, self.INVENTORY)
+ self.assertIsNone(got["image_reference"])
+ self.assertEqual(got["error"], "container_not_in_inventory")
+
+ def test_inventory_not_ready_is_not_a_missing_container(self):
+ got = lxc_apps.resolve_docker_image_for_app(
+ {"container_name": "vaultwarden"}, {"available": False})
+ self.assertEqual(got["error"], "inventory_unavailable")
+
+ def test_app_without_a_container_declares_it(self):
+ got = lxc_apps.resolve_docker_image_for_app({}, self.INVENTORY)
+ self.assertEqual(got["error"], "no_container_declared")
+
+
+class SchedulingExclusionTests(unittest.TestCase):
+ def test_delegated_app_never_holds_the_schedule(self):
+ """It has no release date, so gating on one defers it forever."""
+ gated_ids, _deferred = lxc_apps.partition_scheduled_release_targets(
+ ["app:a1", "app:a2"],
+ [
+ {"id": "a1", "name": "Vaultwarden", "installed_via": "docker_exec",
+ "update_via": "docker", "container_name": "vaultwarden"},
+ {"id": "a2", "name": "Paperless", "installed_via": "file"},
+ ],
+ )
+ self.assertNotIn("a1", gated_ids)
+ self.assertIn("a2", gated_ids)
+
+
+if __name__ == "__main__":
+ unittest.main()
+
+
+class AnnotationTests(unittest.TestCase):
+ """The annotation writes into dicts that live in event-invalidated caches."""
+
+ INVENTORY = {
+ "available": True,
+ "images": [{
+ "reference": "jokobsk/netalertx:latest", "used_by": ["netalertx"],
+ "available_version": "26.9.0", "update_available": True,
+ }],
+ "containers": [{"name": "netalertx", "compose": None}],
+ "update_units": [],
+ }
+
+ def _app(self, **overrides):
+ app = {"id": "a1", "name": "netalertx", "update_via": "docker",
+ "container_name": "netalertx"}
+ app.update(overrides)
+ return app
+
+ def test_delegated_app_receives_its_image_version(self):
+ apps = [self._app()]
+ lxc_apps.annotate_delegated_apps(apps, self.INVENTORY)
+ self.assertEqual(apps[0]["docker_available_version"], "26.9.0")
+ self.assertIs(apps[0]["docker_update_available"], True)
+
+ def test_stale_version_is_cleared_when_the_container_disappears(self):
+ """A renamed container must not keep advertising its old image."""
+ apps = [self._app(docker_available_version="26.9.0", docker_update_available=True)]
+ gone = {"available": True, "images": [], "containers": [], "update_units": []}
+ lxc_apps.annotate_delegated_apps(apps, gone)
+ self.assertIsNone(apps[0]["docker_available_version"])
+ self.assertIsNone(apps[0]["docker_update_available"])
+
+ def test_apps_that_do_not_delegate_are_untouched(self):
+ apps = [{"id": "b1", "name": "Paperless", "installed_via": "file",
+ "state": {"latest_version": "2.9.0"}}]
+ lxc_apps.annotate_delegated_apps(apps, self.INVENTORY)
+ self.assertNotIn("docker_available_version", apps[0])
+
+ def test_a_broken_inventory_never_raises(self):
+ """Decoration must not be able to cost the caller its response."""
+ apps = [self._app()]
+ lxc_apps.annotate_delegated_apps(apps, {"available": True, "images": "not-a-list"})
+ self.assertIsNone(apps[0].get("docker_available_version"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/AppImage/scripts/tests/test_docker_remote_image_version.py b/AppImage/scripts/tests/test_docker_remote_image_version.py
new file mode 100644
index 00000000..c3c9c77c
--- /dev/null
+++ b/AppImage/scripts/tests/test_docker_remote_image_version.py
@@ -0,0 +1,340 @@
+"""Resolving the version a Docker image pull would install.
+
+The registry answers the question the Updates tab actually asks: not "what is
+the newest upstream release" but "what do I get if I re-pull this tag". These
+tests pin the rules that keep that number honest — and the cases where the
+honest answer is no number at all.
+"""
+
+import hashlib
+import json
+import sys
+import unittest
+from pathlib import Path
+from unittest import mock
+
+SCRIPTS_DIR = Path(__file__).resolve().parents[1]
+if str(SCRIPTS_DIR) not in sys.path:
+ sys.path.insert(0, str(SCRIPTS_DIR))
+
+import lxc_apps
+
+
+def _blob(payload: dict) -> tuple[bytes, str]:
+ body = json.dumps(payload).encode("utf-8")
+ return body, "sha256:" + hashlib.sha256(body).hexdigest()
+
+
+def _image_index(platform_digest: str) -> dict:
+ """An index shaped like the real ones: platforms plus attestations."""
+ return {
+ "mediaType": "application/vnd.oci.image.index.v1+json",
+ "manifests": [
+ {"digest": platform_digest,
+ "platform": {"os": "linux", "architecture": "amd64"}},
+ {"digest": "sha256:" + "b" * 64,
+ "platform": {"os": "linux", "architecture": "arm64", "variant": "v8"}},
+ {"digest": "sha256:" + "c" * 64,
+ "platform": {"os": "unknown", "architecture": "unknown"},
+ "annotations": {"vnd.docker.reference.type": "attestation-manifest"}},
+ ],
+ }
+
+
+def _manifest(config_digest: str) -> dict:
+ return {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "config": {
+ "mediaType": "application/vnd.oci.image.config.v1+json",
+ "digest": config_digest,
+ },
+ }
+
+
+class RemoteImageVersionTests(unittest.TestCase):
+ def setUp(self):
+ with lxc_apps._docker_remote_config_lock:
+ lxc_apps._docker_remote_config_cache.clear()
+
+ def _wire(self, remote_version="3.1.0", labels=None, config_media_type=None):
+ """Serve a three-hop index -> manifest -> config walk from memory."""
+ config_payload = {"config": {"Labels": labels if labels is not None else {
+ "org.opencontainers.image.version": remote_version,
+ }}}
+ config_body, config_digest = _blob(config_payload)
+ manifest = _manifest(config_digest)
+ if config_media_type is not None:
+ manifest["config"]["mediaType"] = config_media_type
+ manifest_body, manifest_digest = _blob(manifest)
+ index_body, index_digest = _blob(_image_index(manifest_digest))
+ documents = {
+ index_digest: index_body,
+ manifest_digest: manifest_body,
+ config_digest: config_body,
+ }
+ calls = []
+
+ def fake_request(url, headers, method="HEAD", token=None, max_bytes=0,
+ follow_redirect=True):
+ calls.append(url)
+ digest = url.rsplit("/", 1)[-1]
+ if digest in documents:
+ return {}, documents[digest], "token", None
+ return None, None, token, f"registry HTTP 404 for {digest}"
+
+ return index_digest, calls, mock.patch.object(lxc_apps, "_registry_request", fake_request)
+
+ def _item(self, index_digest, **overrides):
+ item = {
+ "api_host": "ghcr.io",
+ "repository": "immich-app/immich-server",
+ "reference": "ghcr.io/immich-app/immich-server:release",
+ "remote_digest": index_digest,
+ "installed_version": "3.0.1",
+ "installed_version_source": "image_label:org.opencontainers.image.version",
+ "platform": {"os": "linux", "architecture": "amd64", "variant": ""},
+ }
+ item.update(overrides)
+ return item
+
+ def test_resolves_version_from_the_remote_image_config(self):
+ index_digest, calls, patch = self._wire()
+ with patch:
+ version, source = lxc_apps._docker_available_version_from_registry(
+ self._item(index_digest))
+ self.assertEqual(version, "3.1.0")
+ self.assertEqual(source, "remote_image_label:org.opencontainers.image.version")
+ self.assertEqual(len(calls), 3, "index, platform manifest and config blob")
+
+ def test_attestation_manifest_is_never_selected(self):
+ """Its config is a provenance document, not an image."""
+ digest = lxc_apps._select_platform_manifest(
+ _image_index("sha256:" + "a" * 64),
+ {"os": "linux", "architecture": "amd64"},
+ )
+ self.assertEqual(digest, "sha256:" + "a" * 64)
+
+ def test_arm64_v8_matches_bare_arm64(self):
+ digest = lxc_apps._select_platform_manifest(
+ _image_index("sha256:" + "a" * 64),
+ {"os": "linux", "architecture": "arm64", "variant": ""},
+ )
+ self.assertEqual(digest, "sha256:" + "b" * 64)
+
+ def test_missing_platform_yields_no_version(self):
+ """No build for this host means a pull would fail; claim nothing."""
+ index_digest, _calls, patch = self._wire()
+ with patch:
+ version, source = lxc_apps._docker_available_version_from_registry(
+ self._item(index_digest, platform={"os": "linux", "architecture": "riscv64"}))
+ self.assertIsNone(version)
+ self.assertEqual(source, "remote_platform_missing")
+
+ def test_same_version_rebuilt_reports_no_number(self):
+ index_digest, _calls, patch = self._wire(remote_version="3.0.1")
+ with patch:
+ version, source = lxc_apps._docker_available_version_from_registry(
+ self._item(index_digest))
+ self.assertIsNone(version)
+ self.assertEqual(source, "version_not_comparable")
+
+ def test_lower_remote_version_is_rejected(self):
+ index_digest, _calls, patch = self._wire(remote_version="2.9.0")
+ with patch:
+ version, _source = lxc_apps._docker_available_version_from_registry(
+ self._item(index_digest))
+ self.assertIsNone(version)
+
+ def test_linuxserver_build_suffix_is_an_update(self):
+ """LinuxServer bumps the -lsNNN build; the app version may not move.
+
+ Label shape taken from the live lscr.io/linuxserver/radarr image.
+ """
+ index_digest, _calls, patch = self._wire(
+ labels={"org.opencontainers.image.version": "6.3.0.10514-ls314",
+ "build_version": "Linuxserver.io version:- 6.3.0.10514-ls314"})
+ with patch:
+ version, source = lxc_apps._docker_available_version_from_registry(
+ self._item(index_digest, installed_version="6.3.0.10514-ls313"))
+ self.assertEqual(version, "6.3.0.10514-ls314")
+ self.assertEqual(source, "remote_image_label:org.opencontainers.image.version")
+
+ def test_label_key_must_match_the_installed_side(self):
+ """Two publishers' label schemes are not a comparison."""
+ index_digest, _calls, patch = self._wire()
+ with patch:
+ version, source = lxc_apps._docker_available_version_from_registry(
+ self._item(index_digest, installed_version_source="image_label:Version"))
+ self.assertIsNone(version)
+ self.assertEqual(source, "version_source_mismatch")
+
+ def test_config_without_labels_reports_no_version(self):
+ index_digest, _calls, patch = self._wire(labels={})
+ with patch:
+ version, source = lxc_apps._docker_available_version_from_registry(
+ self._item(index_digest))
+ self.assertIsNone(version)
+ self.assertEqual(source, "remote_no_labels")
+
+ def test_moving_tag_label_is_rejected(self):
+ index_digest, _calls, patch = self._wire(
+ labels={"org.opencontainers.image.version": "main"})
+ with patch:
+ version, source = lxc_apps._docker_available_version_from_registry(
+ self._item(index_digest))
+ self.assertIsNone(version)
+ self.assertEqual(source, "remote_no_labels")
+
+ def test_non_image_artifact_is_unsupported(self):
+ index_digest, _calls, patch = self._wire(
+ config_media_type="application/vnd.cncf.helm.config.v1+json")
+ with patch:
+ version, source = lxc_apps._docker_available_version_from_registry(
+ self._item(index_digest))
+ self.assertIsNone(version)
+ self.assertEqual(source, "remote_unsupported_manifest")
+
+ def test_tampered_document_is_discarded(self):
+ """Documents are content-addressed; a mismatch is not trusted."""
+ def fake_request(url, headers, method="HEAD", token=None, max_bytes=0,
+ follow_redirect=True):
+ return {}, b'{"mediaType": "application/vnd.oci.image.manifest.v1+json"}', "t", None
+
+ with mock.patch.object(lxc_apps, "_registry_request", fake_request):
+ version, source = lxc_apps._docker_available_version_from_registry(
+ self._item("sha256:" + "f" * 64))
+ self.assertIsNone(version)
+ self.assertEqual(source, "remote_fetch_error")
+
+ def test_registry_error_leaves_the_digest_verdict_alone(self):
+ def fake_request(url, headers, method="HEAD", token=None, max_bytes=0,
+ follow_redirect=True):
+ return None, None, None, "registry network error: timed out"
+
+ item = self._item("sha256:" + "e" * 64, update_available=True)
+ with mock.patch.object(lxc_apps, "_registry_request", fake_request):
+ version, source = lxc_apps._docker_available_version_from_registry(item)
+ self.assertIsNone(version)
+ self.assertEqual(source, "remote_fetch_error")
+ self.assertIs(item["update_available"], True, "the digest verdict stands on its own")
+
+ def test_second_image_with_the_same_digest_is_served_from_cache(self):
+ index_digest, calls, patch = self._wire()
+ with patch:
+ lxc_apps._docker_available_version_from_registry(self._item(index_digest))
+ lxc_apps._docker_available_version_from_registry(self._item(index_digest))
+ self.assertEqual(len(calls), 3, "the second lookup must not hit the registry")
+
+
+class RegistryRedirectTests(unittest.TestCase):
+ def test_authorization_is_dropped_on_the_cdn_hop(self):
+ """Signed storage URLs reject a second auth mechanism."""
+ seen = []
+
+ def fake_open(url, headers, method, max_bytes):
+ seen.append((url, dict(headers)))
+ if "blobs" in url:
+ return {"Location": "https://cdn.example/blob"}, None, 307, "https://cdn.example/blob", None
+ return {}, b"{}", 200, None, None
+
+ with mock.patch.object(lxc_apps, "_registry_open", fake_open):
+ lxc_apps._registry_request(
+ "https://ghcr.io/v2/x/y/blobs/sha256:abc",
+ {"User-Agent": "ProxMenux-Monitor", "Authorization": "Bearer secret"},
+ method="GET", max_bytes=1024,
+ )
+ self.assertEqual(len(seen), 2)
+ self.assertIn("Authorization", seen[0][1])
+ self.assertNotIn("Authorization", seen[1][1])
+
+ def test_digest_request_keeps_its_contract(self):
+ def fake_open(url, headers, method, max_bytes):
+ return {"Docker-Content-Digest": "sha256:deadbeef"}, None, 200, None, None
+
+ with mock.patch.object(lxc_apps, "_registry_open", fake_open):
+ digest, error = lxc_apps._registry_digest_request("https://ghcr.io/v2/x/y/manifests/latest", {})
+ self.assertEqual(digest, "sha256:deadbeef")
+ self.assertIsNone(error)
+
+
+if __name__ == "__main__":
+ unittest.main()
+
+
+class ContainerIdentityTests(unittest.TestCase):
+ """A container's catalog identity comes from a detector that declares it."""
+
+ HINTS = {
+ "immich": {
+ "installed_via": "file",
+ "file_path": "/root/.immich",
+ "logo": "https://example.invalid/immich.webp",
+ "alt_detectors": [
+ {"installed_via": "docker_label", "container_name": "immich_server",
+ "label": "org.opencontainers.image.version"},
+ ],
+ },
+ "netalertx": {
+ "installed_via": "docker_label",
+ "container_name": "netalertx",
+ "label": "org.opencontainers.image.version",
+ },
+ "postgresql": {"installed_via": "dpkg", "package": "postgresql"},
+ }
+
+ def test_index_covers_primary_and_alternate_detectors(self):
+ with mock.patch.object(lxc_apps, "_fetch_tracking_hints", lambda: self.HINTS):
+ index = lxc_apps._docker_container_slug_index()
+ self.assertEqual(index.get("immich_server"), "immich")
+ self.assertEqual(index.get("netalertx"), "netalertx")
+
+ def test_unclaimed_container_names_stay_out(self):
+ """A container merely called postgres is not an application claim."""
+ with mock.patch.object(lxc_apps, "_fetch_tracking_hints", lambda: self.HINTS):
+ index = lxc_apps._docker_container_slug_index()
+ self.assertNotIn("postgres", index)
+
+ def test_declared_container_resolves_a_name_heuristics_cannot(self):
+ with mock.patch.object(lxc_apps, "_fetch_tracking_hints", lambda: self.HINTS), \
+ mock.patch.object(lxc_apps, "_catalog_lookup", lambda slug: {"name": "Immich"} if slug == "immich" else None):
+ meta = lxc_apps._docker_service_catalog_meta(
+ "immich-server", "immich_server",
+ "ghcr.io/immich-app/immich-server:release",
+ )
+ self.assertEqual(meta["slug"], "immich")
+ self.assertEqual(meta["name"], "Immich")
+ self.assertEqual(meta["logo_url"], "https://example.invalid/immich.webp")
+
+
+class UpdateNotificationWordingTests(unittest.TestCase):
+ def test_alert_leads_with_the_application_name(self):
+ payload = lxc_apps._docker_stack_notification_payload(
+ 102,
+ {"state": {}},
+ {"images": [{
+ "reference": "vaultwarden/server:latest",
+ "display_name": "Vaultwarden",
+ "installed_version": "1.37.2",
+ "available_version": "1.38.0",
+ "update_available": True,
+ "remote_digest": "sha256:" + "a" * 64,
+ }]},
+ "vaultwarden",
+ )
+ self.assertIsNotNone(payload)
+ self.assertIn("• Vaultwarden (vaultwarden/server:latest): 1.37.2 → 1.38.0", payload["details"])
+
+ def test_unnamed_image_keeps_its_reference(self):
+ payload = lxc_apps._docker_stack_notification_payload(
+ 102,
+ {"state": {}},
+ {"images": [{
+ "reference": "valkey/valkey:8-bookworm",
+ "installed_version": None,
+ "available_version": None,
+ "update_available": True,
+ "remote_digest": "sha256:" + "b" * 64,
+ }]},
+ "immich",
+ )
+ self.assertIn("• valkey/valkey:8-bookworm: new registry digest", payload["details"])
diff --git a/json/runtime_verified_overrides.json b/json/runtime_verified_overrides.json
index 5da302ff..a971f5ac 100644
--- a/json/runtime_verified_overrides.json
+++ b/json/runtime_verified_overrides.json
@@ -248,6 +248,15 @@
"installed_via": "file",
"file_path": "/root/.vaultwarden",
"file_regex": "v?(\\d+\\.\\d+\\.\\d+)"
+ },
+ {
+ "installed_via": "docker_exec",
+ "container_name": "vaultwarden",
+ "binary_path": "/vaultwarden",
+ "binary_args": [
+ "--version"
+ ],
+ "installed_regex": "(?im)^Vaultwarden\\s+v?(\\d+\\.\\d+\\.\\d+)"
}
]
},
@@ -361,6 +370,52 @@
"distribution": "searxng",
"installed_regex": "(\\d+\\.\\d+\\.\\d+\\+[0-9a-f]+)"
}
+ },
+ "technitiumdns": {
+ "name": "Technitium DNS Server",
+ "detector": {
+ "installed_via": "file",
+ "file_path": "/root/.technitium",
+ "file_regex": "(\\d+(?:\\.\\d+){1,3})",
+ "upstream_type": "http_json",
+ "upstream_url": "https://go.technitium.com/?id=42",
+ "upstream_json_path": "updateVersion"
+ },
+ "default_ports": [
+ 5380
+ ],
+ "website": "https://technitium.com/dns/"
+ },
+ "netalertx": {
+ "name": "NetAlertX",
+ "install_scope": [
+ "docker"
+ ],
+ "detector": {
+ "installed_via": "docker_label",
+ "container_name": "netalertx",
+ "label": "org.opencontainers.image.version",
+ "repo": "netalertx/NetAlertX",
+ "github_source": "releases",
+ "tag_regex": "v?(\\d+(?:\\.\\d+){1,3})"
+ }
+ },
+ "immich": {
+ "detector": {
+ "installed_via": "file",
+ "file_path": "/root/.immich",
+ "file_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
+ "repo": "immich-app/immich",
+ "github_source": "releases",
+ "tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)"
+ },
+ "alt_detectors": [
+ {
+ "installed_via": "docker_label",
+ "container_name": "immich_server",
+ "label": "org.opencontainers.image.version"
+ }
+ ]
}
}
}