feat(docker): name containerised applications and the versions they run

An LXC running its workload in Docker could not answer two questions it
already had the data for: which application is in there, and whether a
newer version exists.

**Which version is available.** The Updates tab resolved that number only
for docker.io, and only when a version tag happened to share the digest of
the tag in use. On ghcr.io, lscr.io or quay.io the row said "New image
available" with no number at all. The image a pull would install carries
its own version label, so it is now read from the registry by digest —
over the same protocol and Bearer challenge the digest comparison already
uses, and through the same label lookup the installed version uses, now
shared as _docker_version_from_labels.

The question this answers is the one the tab asks: what do I get if I
re-pull this tag. Not "what is the newest upstream release", which is a
different number whenever a tag is pinned or the publisher tags releases
differently from images.

Docker Hub keeps priority on docker.io: its tag API is not a pull and does
not spend the anonymous pull-rate budget, and official images carry no
labels for the registry path to read. The digest still decides whether an
update exists; this only names it, and declines to name it when the answer
would be a guess — no build for this platform, no labels, an unreadable
manifest, a moving tag, a rebuild of the same version, or two sides whose
versions came from different label keys. Each refusal is recorded in
available_version_source.

Attestation manifests are skipped explicitly: they advertise
unknown/unknown and their config blob is a provenance document, not an
image. Every document is fetched by digest and verified against it, the
config read is bounded, and it is cached per digest, which never changes
content. The CDN redirect is followed by hand, dropping Authorization:
urllib re-sends it to the redirect target and signed-URL storage rejects a
second auth mechanism.

**Which application it is.** The probe already read "1.37.2" out of a
Vaultwarden container and get_suggestions discarded it, so the panel
answered "No new applications were detected" about an application whose
version it had just measured. Containerised applications are now offered
for registration like any other, with their name, logo, published ports
and installed version.

What they do not get is an update path of their own, because they do not
have one: updating Vaultwarden means pulling and recreating its image. A
new update_via=docker marker records that delegation, so one release stays
one badge, one notification and one button. The marker is validated rather
than inferred from installed_via, since docker_exec with an upstream is a
legitimate registration someone may already rely on; combining it with an
upstream is rejected instead of silently stripped, because registering an
app that checks GitHub behind a delegation promising it will not is worse
than an error message.

Three failure modes the delegation had to be defended against: detector
auto-healing would have migrated the app onto a leftover /root/.<app>
marker and quietly un-delegated it; saving replaces the whole record, so
the editor carries the marker explicitly rather than dropping it on the
first port edit; and the release-age hold gates on a publish date a
delegated app never has, which deferred the whole schedule forever.

Their version is resolved server-side through the container the detector
declares — not through the app's name or image, since Immich's compose
service and image are both immich-server while the application is immich.
The annotation happens on the way out of both endpoints rather than into
their caches: the App tab's cache is invalidated by events, not by time,
and the Docker inventory it reads is built asynchronously, so annotating
before storing froze a response taken before the first scan.

The rows carry that name too. display_name was already computed and
already used by the bulk-update section; the image row, the update
notification and the CT badge now use it as well. A delegated app's
pending update counts in the badge only while its image is not already
being counted, so registering just the application does not leave the
container looking up to date, and registering both does not count twice.

Catalog: four detectors verified on real containers, following the rules
in the file. vaultwarden and immich gain docker fallbacks for installs
where the native marker does not exist. netalertx is new — note its
repository is netalertx/NetAlertX; the Docker Hub namespace 404s.
technitiumdns is new and uses Technitium's own update endpoint rather than
GitHub releases: its marker reads 15.4 while the release tag is v15.4.0,
and _version_tuple compares (15,4,0) > (15,4) as an update that would
never clear.

Verified live against ghcr.io (Immich 3.1.0), docker.io (Vaultwarden
1.37.2) and lscr.io (Radarr 6.3.0.10514-ls314), plus postgres:16, which
correctly reports no version because official images carry no labels.
Exercised end to end on Proxmox VE 9.2.4 with NetAlertX reporting
26.6.3 -> 26.9.0. 31 new unit tests cover the resolution rules, every
refusal, the delegation contract and the container-to-image pairing.
This commit is contained in:
byGarcia
2026-09-04 13:16:28 +02:00
parent 5e60feab84
commit de72e18a77
8 changed files with 1390 additions and 66 deletions
+150 -6
View File
@@ -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 (
<div className="rounded-md border border-border/60 bg-background/40 p-3">
<div className="text-xs font-medium text-foreground">
{t("vmLxc.appEditor.upstreamDelegatedTitle")}
</div>
<div className="text-[10px] text-muted-foreground mt-1 leading-relaxed">
{t("vmLxc.appEditor.upstreamDelegatedHelp")}
</div>
</div>
)
}
return (
<>
<div>
@@ -2227,6 +2302,67 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</div>
)
// 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) => (
<div key={`workload-${w.slug}`} className="p-3 rounded-md border border-border/60 bg-background/40">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
{w.logo_url && (
<ThemeAwareLogo
src={w.logo_url}
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
/>
)}
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground truncate">{w.name}</div>
<div className="text-xs text-emerald-400/90">
{w.installed_version
? t("vmLxc.appEditor.versionDetected", { version: w.installed_version })
: t("vmLxc.appEditor.detectedInContainer")}
</div>
<div className="text-[10px] text-muted-foreground mt-0.5 truncate">
{t("vmLxc.appEditor.runsInsideDocker")}
{w.container_name ? ` · ${w.container_name}` : ""}
</div>
</div>
</div>
<div className="flex flex-row gap-2 flex-shrink-0 sm:justify-end w-full sm:w-auto">
<Button
size="sm"
onClick={() => openEditor(undefined, {
withTracking: !!w.tracking_suggestion,
preset: {
slug: w.slug,
name: w.name,
logo_url: w.logo_url || "",
default_ports: w.default_ports || [],
category: w.category || null,
tracking_suggestion: w.tracking_suggestion || null,
} as unknown as DetectedApp,
})}
className="flex-[3] sm:flex-none bg-blue-500 hover:bg-blue-600 text-white"
>
<PlusCircle className="h-3.5 w-3.5 mr-1" />
{t("vmLxc.appEditor.registerButton")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => dismissDetection(w.slug, w.name)}
aria-label={`Hide ${w.name} detection`}
title={t("vmLxc.appEditor.hidePermanentlyTooltip")}
className="flex-1 sm:flex-none bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 hover:text-red-300"
>
<EyeOff className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{t("vmLxc.appEditor.hideButton")}</span>
</Button>
</div>
</div>
</div>
)
return (
<div className="space-y-4">
{/* Browse panel — surfaces hidden detections with Restore
@@ -2288,9 +2424,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
<p className="text-xs text-muted-foreground max-w-md mx-auto leading-relaxed text-center">
{t("vmLxc.appEditor.noAppsBody")}
</p>
{visibleDetected.length > 0 && (
{(visibleDetected.length > 0 || visibleWorkloads.length > 0) && (
<div className="space-y-2 pt-2">
{visibleDetected.map(renderDetectionChip)}
{visibleWorkloads.map(renderWorkloadChip)}
</div>
)}
<div className="pt-1 flex flex-wrap justify-center gap-2">
@@ -2394,8 +2531,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</div>
{(() => {
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 (
<div className={"mb-3 grid gap-3 " + (st?.installed_version && hasUpstream ? "grid-cols-2" : "grid-cols-1")}>
@@ -2413,8 +2556,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
{t("vmLxc.appEditor.latestUpstream")}
</div>
<div className={"text-lg font-semibold font-mono flex items-center gap-2 " + (hasUpdate ? "text-purple-400" : "text-foreground")}>
{st?.latest_version || <span className="text-muted-foreground text-base font-normal">{t("vmLxc.appEditor.checkingStatus")}</span>}
{hasUpdate && st?.latest_version && (
{latestVersion || <span className="text-muted-foreground text-base font-normal">{t("vmLxc.appEditor.checkingStatus")}</span>}
{hasUpdate && latestVersion && (
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
)}
</div>
@@ -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) && (
<div className="space-y-2">
<div className="text-xs uppercase tracking-wider text-muted-foreground">
{t("vmLxc.appEditor.alsoDetectedContainer")}
</div>
{unregisteredDetected.map(renderDetectionChip)}
{visibleWorkloads.map(renderWorkloadChip)}
</div>
)}
+115 -12
View File
@@ -90,6 +90,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
@@ -246,7 +255,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 {
@@ -2532,14 +2550,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
}
@@ -5159,7 +5210,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")
@@ -5265,6 +5322,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) => (
@@ -5613,9 +5673,23 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)}
</div>
<div className="min-w-0">
<span className="font-mono text-foreground/90 block truncate" title={image.reference}>
{image.reference}
</span>
{image.display_name ? (
<>
<span className="text-foreground block truncate" title={image.display_name}>
{image.display_name}
</span>
<span
className="font-mono text-xs text-muted-foreground block truncate"
title={image.reference}
>
{image.reference}
</span>
</>
) : (
<span className="font-mono text-foreground/90 block truncate" title={image.reference}>
{image.reference}
</span>
)}
<div className="mt-1 text-xs text-muted-foreground flex items-center gap-1.5">
<Package className="h-3.5 w-3.5 flex-shrink-0" />
{image.installed_version ? (
@@ -5977,11 +6051,38 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<Package className="h-4 w-4 flex-shrink-0" />
<span>{t("vmLxc.updates.installedLabel")} <code className="text-foreground/80">{aw.installed_version}</code></span>
</div>
<div>{t("vmLxc.updates.versionTrackingPendingShort")}</div>
{/* "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 ? (
<div className="flex items-center gap-2 text-purple-400">
<ArrowUpCircle className="h-4 w-4 flex-shrink-0" />
<span>
{t("vmLxc.updates.upstreamAvailable", { version: aw.docker_available_version })}
</span>
</div>
) : aw.docker_update_available === true ? (
<div className="flex items-center gap-2 text-purple-400">
<ArrowUpCircle className="h-4 w-4 flex-shrink-0" />
<span>{t("vmLxc.updates.imageUpdateAvailable")}</span>
</div>
) : aw.docker_update_available === false ? (
<div className="flex items-center gap-2 text-green-500">
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
<span>{t("vmLxc.updates.imageUpToDate")}</span>
</div>
) : null
) : (
<div>{t("vmLxc.updates.versionTrackingPendingShort")}</div>
)}
</div>
) : tracksVersion ? (
<div className="text-sm text-muted-foreground">
{t("vmLxc.updates.versionTrackingPendingShort")}
{aw.update_via === "docker"
? t("vmLxc.updates.updatedWithDockerImage")
: t("vmLxc.updates.versionTrackingPendingShort")}
</div>
) : hasCmd ? (
<div className="text-sm text-muted-foreground">
@@ -6016,9 +6117,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
) : (
<p className={`text-xs text-muted-foreground leading-relaxed min-w-0 ${tracksVersion ? "mt-3" : ""}`}>
{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")}
</p>
)}
</>
+6
View File
@@ -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",
+22 -1
View File
@@ -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
+523 -47
View File
@@ -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/.<app> 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/.<app>) 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,
}
@@ -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()
@@ -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"])
+55
View File
@@ -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"
}
]
}
}
}