diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index 94b21fd9..f42eb317 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. @@ -131,6 +134,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 { @@ -221,9 +229,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. @@ -603,6 +627,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)) @@ -633,6 +664,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, @@ -689,6 +730,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 || "", update_method: existing.update_method || (existing.update_command ? "custom" : "none"), hide_no_updater_notice: existing.hide_no_updater_notice === true, @@ -755,6 +800,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) @@ -1835,6 +1894,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 ( +
+
+ {t("vmLxc.appEditor.upstreamDelegatedTitle")} +
+
+ {t("vmLxc.appEditor.upstreamDelegatedHelp")} +
+
+ ) + } return ( <>
@@ -2237,6 +2312,67 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
) + // 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) => ( +
+
+
+ {w.logo_url && ( + + )} +
+
{w.name}
+
+ {w.installed_version + ? t("vmLxc.appEditor.versionDetected", { version: w.installed_version }) + : t("vmLxc.appEditor.detectedInContainer")} +
+
+ {t("vmLxc.appEditor.runsInsideDocker")} + {w.container_name ? ` · ${w.container_name}` : ""} +
+
+
+
+ + +
+
+
+ ) + return (
{/* Browse panel — surfaces hidden detections with Restore @@ -2298,9 +2434,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop

{t("vmLxc.appEditor.noAppsBody")}

- {visibleDetected.length > 0 && ( + {(visibleDetected.length > 0 || visibleWorkloads.length > 0) && (
{visibleDetected.map(renderDetectionChip)} + {visibleWorkloads.map(renderWorkloadChip)}
)}
@@ -2404,8 +2541,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
{(() => { - const hasUpstream = !!(app.repo || app.upstream_type) - const hasUpdate = st?.update_available === true + // A delegated app has no upstream of its own; the version to + // compare against comes from the image it updates with. + const delegated = app.update_via === "docker" + const hasUpstream = !!(app.repo || app.upstream_type) || (delegated && !!app.docker_available_version) + const hasUpdate = delegated + ? app.docker_update_available === true + : st?.update_available === true + const latestVersion = delegated ? app.docker_available_version : st?.latest_version if (!tracking || !(st?.installed_version || hasUpstream)) return null return (
@@ -2423,8 +2566,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop {t("vmLxc.appEditor.latestUpstream")}
- {st?.latest_version || {t("vmLxc.appEditor.checkingStatus")}} - {hasUpdate && st?.latest_version && ( + {latestVersion || {t("vmLxc.appEditor.checkingStatus")}} + {hasUpdate && latestVersion && ( )}
@@ -2571,12 +2714,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) && (
{t("vmLxc.appEditor.alsoDetectedContainer")}
{unregisteredDetected.map(renderDetectionChip)} + {visibleWorkloads.map(renderWorkloadChip)}
)} diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index fe353d65..cd1304ca 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -91,6 +91,15 @@ interface LxcAppWatch { id: string name: string | null installed_via?: string | null + // Set when the app runs inside Docker: its update is the image's, so it + // reports identity and installed version and nothing else. + update_via?: string | null + container_name?: string | null + // Resolved server-side from the image this app delegates to. Kept apart + // from latest_version/update_available so the CT badge counts the release + // once, through the image. + docker_available_version?: string | null + docker_update_available?: boolean | null ports?: LxcAppPort[] logo_url?: string | null health_path?: string | null @@ -258,7 +267,16 @@ function hasLxcPendingUpdates(vm: VMData): boolean { ).length const dockerRegistered = (vm.app_watches || []).some((app) => app.helper_slug === "docker") const dockerUpdates = dockerRegistered ? (vm.docker_inventory?.update_count ?? 0) : 0 - return osUpdates + appUpdates + dockerUpdates > 0 + // An app that delegates to Docker has no update flag of its own, so its + // pending release reaches this count through the image — but only while + // Docker is registered. Registering just the application is now a + // supported choice, and it must not leave the CT looking up to date. + // Counted only when the image is not already counted, so one release + // stays one number. + const delegatedUpdates = dockerRegistered ? 0 : (vm.app_watches || []).filter( + (app) => app.update_via === "docker" && app.docker_update_available === true && !app.exclude_from_badge, + ).length + return osUpdates + appUpdates + dockerUpdates + delegatedUpdates > 0 } function buildRegisteredAppUrl(vm: VMData, port?: LxcAppPort): string | null { @@ -2558,14 +2576,47 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { ).length const dockerRegistered = (vm.app_watches || []).some((a) => a.helper_slug === "docker") const dockerCount = dockerRegistered ? (vm.docker_inventory?.update_count ?? 0) : 0 + // See hasLxcPendingUpdates: a delegated app counts only while its image + // is not already being counted through the Docker section. + const delegatedCount = dockerRegistered ? 0 : (vm.app_watches || []).filter( + (a) => a.update_via === "docker" && a.docker_update_available === true && !a.exclude_from_badge, + ).length const osCount = uc?.count ?? 0 - const total = osCount + appCount + dockerCount - if (!uc && appCount === 0 && dockerCount === 0) return undefined + const total = osCount + appCount + dockerCount + delegatedCount + if (!uc && appCount === 0 && dockerCount === 0 && delegatedCount === 0) return undefined if (total === 0) return uc + // The badge names what is pending, and until now it could only name OS + // packages — so a container whose only pending update was an application + // showed a number that explained nothing. Applications and images join + // that list under the same names they carry everywhere else. + const pendingNames: LxcPackageUpdate[] = [] + for (const a of vm.app_watches || []) { + if (a.exclude_from_badge) continue + const isDelegatedPending = a.update_via === "docker" && a.docker_update_available === true && !dockerRegistered + if (a.update_available !== true && !isDelegatedPending) continue + pendingNames.push({ + name: a.name || "", + current: a.installed_version || "", + latest: (isDelegatedPending ? a.docker_available_version : a.latest_version) || "", + security: false, + }) + } + if (dockerRegistered) { + for (const image of vm.docker_inventory?.images || []) { + if (image.update_available !== true) continue + pendingNames.push({ + name: image.display_name || image.reference, + current: image.installed_version || "", + latest: image.available_version || "", + security: false, + }) + } + } return { ...(uc || {}), count: total, available: true, + packages: [...(uc?.packages || []), ...pendingNames], last_check: uc?.last_check ?? new Date().toISOString(), } as LxcUpdateCheck } @@ -5185,7 +5236,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { }).map((app) => ({ id: `app:${app.id}`, label: app.name })) const versionTrackedScheduleAppIds = new Set( registeredApps - .filter((app) => !!app.installed_via && app.helper_slug !== "docker") + .filter((app) => ( + !!app.installed_via + && app.helper_slug !== "docker" + // A delegated app never resolves a release date; + // holding the schedule on it would defer forever. + && app.update_via !== "docker" + )) .map((app) => `app:${app.id}`), ) const scheduleHasVersionTrackedApps = scheduleTargets.includes("apps") @@ -5292,6 +5349,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { const bulkActionAppIds = new Set(bulkAppChoices.map((choice) => choice.id)) const bulkUnavailableApps = registeredApps.filter((app) => ( app.helper_slug !== "docker" + // Not "unavailable": it updates through the Docker + // unit listed right above in this same section. + && app.update_via !== "docker" && !bulkActionAppIds.has(`app:${app.id}`) )) const pendingDockerBulkTargets = bulkTargets.filter((target) => ( @@ -5640,9 +5700,23 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { )}
- - {image.reference} - + {image.display_name ? ( + <> + + {image.display_name} + + + {image.reference} + + + ) : ( + + {image.reference} + + )}
{image.installed_version ? ( @@ -5970,11 +6044,38 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {t("vmLxc.updates.installedLabel")} {aw.installed_version}
-
{t("vmLxc.updates.versionTrackingPendingShort")}
+ {/* "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 ? ( +
+ + + {t("vmLxc.updates.upstreamAvailable", { version: aw.docker_available_version })} + +
+ ) : aw.docker_update_available === true ? ( +
+ + {t("vmLxc.updates.imageUpdateAvailable")} +
+ ) : aw.docker_update_available === false ? ( +
+ + {t("vmLxc.updates.imageUpToDate")} +
+ ) : null + ) : ( +
{t("vmLxc.updates.versionTrackingPendingShort")}
+ )}
) : tracksVersion ? (
- {t("vmLxc.updates.versionTrackingPendingShort")} + {aw.update_via === "docker" + ? t("vmLxc.updates.updatedWithDockerImage") + : t("vmLxc.updates.versionTrackingPendingShort")}
) : hasCmd ? (
@@ -6009,9 +6110,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
) : (

- {managedByOs - ? t("vmLxc.updates.managedByOsPackages") - : t("vmLxc.updates.noUpdateMethodBody")} + {aw.update_via === "docker" + ? t("vmLxc.updates.updatedWithDockerImage") + : managedByOs + ? t("vmLxc.updates.managedByOsPackages") + : t("vmLxc.updates.noUpdateMethodBody")}

)} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index ed5b0a78..c6a009f4 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1324,6 +1324,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", @@ -1560,6 +1561,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 8de51b32..70c2ed69 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 @@ -13390,6 +13404,9 @@ def api_vm_apps_get(vmid): import lxc_apps sidecar = lxc_apps.load_sidecar(vmid) payload = sidecar if sidecar else {'vmid': vmid, 'apps': []} + # Read the shared, file-versioned snapshot; annotate only on the way + # out so asynchronously collected Docker metadata remains current. + 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 ddf4672c..2a06d4c9 100644 --- a/AppImage/scripts/lxc_apps.py +++ b/AppImage/scripts/lxc_apps.py @@ -185,6 +185,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] = {} @@ -1155,6 +1171,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 @@ -1785,6 +1821,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")) @@ -1794,10 +1846,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). @@ -1818,10 +1869,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]: @@ -1967,45 +2015,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]]: @@ -2018,6 +2145,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. @@ -2207,6 +2494,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. @@ -2453,7 +2798,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, }) @@ -2504,6 +2858,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), @@ -3181,7 +3559,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 @@ -3361,6 +3744,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 @@ -3428,12 +3816,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 { @@ -3515,6 +3910,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 @@ -3775,6 +4178,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 @@ -4358,6 +4763,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. @@ -4368,6 +4791,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: @@ -4683,6 +5113,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 @@ -4707,6 +5143,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: @@ -4779,5 +5254,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" + } + ] } } }