mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-15 19:26:45 +00:00
Merge pull request #337 from byGarcia/feature/docker-remote-image-version
Report the version a Docker image would install, and let the apps inside it be registered
This commit is contained in:
@@ -92,6 +92,9 @@ interface AppConfig {
|
|||||||
health_path?: string
|
health_path?: string
|
||||||
logo_url?: string
|
logo_url?: string
|
||||||
helper_slug?: 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.
|
// Preserved here even though this editor does not execute updates.
|
||||||
// The backend uses full-record replacement, so omitting these when
|
// The backend uses full-record replacement, so omitting these when
|
||||||
// editing ports/tracking would silently erase the Updates-tab setup.
|
// editing ports/tracking would silently erase the Updates-tab setup.
|
||||||
@@ -131,6 +134,11 @@ interface AppEntry extends AppConfig {
|
|||||||
id: string
|
id: string
|
||||||
state?: AppState
|
state?: AppState
|
||||||
created_at?: string
|
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 {
|
interface SidecarResponse {
|
||||||
@@ -221,9 +229,25 @@ interface Suggestions {
|
|||||||
logo_url?: string | null
|
logo_url?: string | null
|
||||||
category?: string | null
|
category?: string | null
|
||||||
extras?: DetectedApp[]
|
extras?: DetectedApp[]
|
||||||
|
docker_workloads?: DockerWorkload[]
|
||||||
docker_web_links?: DockerWebLinkSuggestion[]
|
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
|
// Compact catalog entry — one row for every registerable app the
|
||||||
// picker can offer. Fetched once from /api/apps/catalog on panel
|
// picker can offer. Fetched once from /api/apps/catalog on panel
|
||||||
// mount, filtered client-side while the user types.
|
// 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).
|
// Alias for pre-existing consumers (post-registration chip strip).
|
||||||
const unregisteredDetected = visibleDetected
|
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-
|
// Detections the user hid and could restore from the Register-a-
|
||||||
// different-app panel. Not affected by registration state.
|
// different-app panel. Not affected by registration state.
|
||||||
const hiddenDetections = detectedList.filter((d) => dismissedSlugs.has(d.slug))
|
const hiddenDetections = detectedList.filter((d) => dismissedSlugs.has(d.slug))
|
||||||
@@ -633,6 +664,16 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
found: true,
|
found: true,
|
||||||
text: t("vmLxc.appEditor.newApplicationsDetected", { count: newCount }),
|
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 {
|
} else {
|
||||||
setDetectionNotice({
|
setDetectionNotice({
|
||||||
found: false,
|
found: false,
|
||||||
@@ -689,6 +730,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
health_path: existing.health_path || "",
|
health_path: existing.health_path || "",
|
||||||
logo_url: existing.logo_url || "",
|
logo_url: existing.logo_url || "",
|
||||||
helper_slug: existing.helper_slug || "",
|
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_command: existing.update_command || "",
|
||||||
update_method: existing.update_method || (existing.update_command ? "custom" : "none"),
|
update_method: existing.update_method || (existing.update_command ? "custom" : "none"),
|
||||||
hide_no_updater_notice: existing.hide_no_updater_notice === true,
|
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 || "",
|
docker_image: (t as any).docker_image || "",
|
||||||
tag_regex: t.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)",
|
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)
|
setShowAdvanced(true)
|
||||||
} else {
|
} else {
|
||||||
setShowAdvanced(false)
|
setShowAdvanced(false)
|
||||||
@@ -1835,6 +1894,22 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
if (!t) patch.tag_regex = ""
|
if (!t) patch.tag_regex = ""
|
||||||
setField(patch)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
@@ -2237,6 +2312,67 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Browse panel — surfaces hidden detections with Restore
|
{/* Browse panel — surfaces hidden detections with Restore
|
||||||
@@ -2298,9 +2434,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">
|
<p className="text-xs text-muted-foreground max-w-md mx-auto leading-relaxed text-center">
|
||||||
{t("vmLxc.appEditor.noAppsBody")}
|
{t("vmLxc.appEditor.noAppsBody")}
|
||||||
</p>
|
</p>
|
||||||
{visibleDetected.length > 0 && (
|
{(visibleDetected.length > 0 || visibleWorkloads.length > 0) && (
|
||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
{visibleDetected.map(renderDetectionChip)}
|
{visibleDetected.map(renderDetectionChip)}
|
||||||
|
{visibleWorkloads.map(renderWorkloadChip)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="pt-1 flex flex-wrap justify-center gap-2">
|
<div className="pt-1 flex flex-wrap justify-center gap-2">
|
||||||
@@ -2404,8 +2541,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const hasUpstream = !!(app.repo || app.upstream_type)
|
// A delegated app has no upstream of its own; the version to
|
||||||
const hasUpdate = st?.update_available === true
|
// 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
|
if (!tracking || !(st?.installed_version || hasUpstream)) return null
|
||||||
return (
|
return (
|
||||||
<div className={"mb-3 grid gap-3 " + (st?.installed_version && hasUpstream ? "grid-cols-2" : "grid-cols-1")}>
|
<div className={"mb-3 grid gap-3 " + (st?.installed_version && hasUpstream ? "grid-cols-2" : "grid-cols-1")}>
|
||||||
@@ -2423,8 +2566,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
{t("vmLxc.appEditor.latestUpstream")}
|
{t("vmLxc.appEditor.latestUpstream")}
|
||||||
</div>
|
</div>
|
||||||
<div className={"text-lg font-semibold font-mono flex items-center gap-2 " + (hasUpdate ? "text-purple-400" : "text-foreground")}>
|
<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>}
|
{latestVersion || <span className="text-muted-foreground text-base font-normal">{t("vmLxc.appEditor.checkingStatus")}</span>}
|
||||||
{hasUpdate && st?.latest_version && (
|
{hasUpdate && latestVersion && (
|
||||||
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
|
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -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
|
hasn't been registered yet is shown as a chip with a
|
||||||
one-click Register button. Filtered against the sidecar's
|
one-click Register button. Filtered against the sidecar's
|
||||||
`helper_slug` field so a registered app never re-appears. */}
|
`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="space-y-2">
|
||||||
<div className="text-xs uppercase tracking-wider text-muted-foreground">
|
<div className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
{t("vmLxc.appEditor.alsoDetectedContainer")}
|
{t("vmLxc.appEditor.alsoDetectedContainer")}
|
||||||
</div>
|
</div>
|
||||||
{unregisteredDetected.map(renderDetectionChip)}
|
{unregisteredDetected.map(renderDetectionChip)}
|
||||||
|
{visibleWorkloads.map(renderWorkloadChip)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,15 @@ interface LxcAppWatch {
|
|||||||
id: string
|
id: string
|
||||||
name: string | null
|
name: string | null
|
||||||
installed_via?: 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[]
|
ports?: LxcAppPort[]
|
||||||
logo_url?: string | null
|
logo_url?: string | null
|
||||||
health_path?: string | null
|
health_path?: string | null
|
||||||
@@ -258,7 +267,16 @@ function hasLxcPendingUpdates(vm: VMData): boolean {
|
|||||||
).length
|
).length
|
||||||
const dockerRegistered = (vm.app_watches || []).some((app) => app.helper_slug === "docker")
|
const dockerRegistered = (vm.app_watches || []).some((app) => app.helper_slug === "docker")
|
||||||
const dockerUpdates = dockerRegistered ? (vm.docker_inventory?.update_count ?? 0) : 0
|
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 {
|
function buildRegisteredAppUrl(vm: VMData, port?: LxcAppPort): string | null {
|
||||||
@@ -2558,14 +2576,47 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
).length
|
).length
|
||||||
const dockerRegistered = (vm.app_watches || []).some((a) => a.helper_slug === "docker")
|
const dockerRegistered = (vm.app_watches || []).some((a) => a.helper_slug === "docker")
|
||||||
const dockerCount = dockerRegistered ? (vm.docker_inventory?.update_count ?? 0) : 0
|
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 osCount = uc?.count ?? 0
|
||||||
const total = osCount + appCount + dockerCount
|
const total = osCount + appCount + dockerCount + delegatedCount
|
||||||
if (!uc && appCount === 0 && dockerCount === 0) return undefined
|
if (!uc && appCount === 0 && dockerCount === 0 && delegatedCount === 0) return undefined
|
||||||
if (total === 0) return uc
|
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 {
|
return {
|
||||||
...(uc || {}),
|
...(uc || {}),
|
||||||
count: total,
|
count: total,
|
||||||
available: true,
|
available: true,
|
||||||
|
packages: [...(uc?.packages || []), ...pendingNames],
|
||||||
last_check: uc?.last_check ?? new Date().toISOString(),
|
last_check: uc?.last_check ?? new Date().toISOString(),
|
||||||
} as LxcUpdateCheck
|
} as LxcUpdateCheck
|
||||||
}
|
}
|
||||||
@@ -5185,7 +5236,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
}).map((app) => ({ id: `app:${app.id}`, label: app.name }))
|
}).map((app) => ({ id: `app:${app.id}`, label: app.name }))
|
||||||
const versionTrackedScheduleAppIds = new Set(
|
const versionTrackedScheduleAppIds = new Set(
|
||||||
registeredApps
|
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}`),
|
.map((app) => `app:${app.id}`),
|
||||||
)
|
)
|
||||||
const scheduleHasVersionTrackedApps = scheduleTargets.includes("apps")
|
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 bulkActionAppIds = new Set(bulkAppChoices.map((choice) => choice.id))
|
||||||
const bulkUnavailableApps = registeredApps.filter((app) => (
|
const bulkUnavailableApps = registeredApps.filter((app) => (
|
||||||
app.helper_slug !== "docker"
|
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}`)
|
&& !bulkActionAppIds.has(`app:${app.id}`)
|
||||||
))
|
))
|
||||||
const pendingDockerBulkTargets = bulkTargets.filter((target) => (
|
const pendingDockerBulkTargets = bulkTargets.filter((target) => (
|
||||||
@@ -5640,9 +5700,23 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="font-mono text-foreground/90 block truncate" title={image.reference}>
|
{image.display_name ? (
|
||||||
{image.reference}
|
<>
|
||||||
</span>
|
<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">
|
<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" />
|
<Package className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
{image.installed_version ? (
|
{image.installed_version ? (
|
||||||
@@ -5970,11 +6044,38 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
<Package className="h-4 w-4 flex-shrink-0" />
|
<Package className="h-4 w-4 flex-shrink-0" />
|
||||||
<span>{t("vmLxc.updates.installedLabel")} <code className="text-foreground/80">{aw.installed_version}</code></span>
|
<span>{t("vmLxc.updates.installedLabel")} <code className="text-foreground/80">{aw.installed_version}</code></span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : tracksVersion ? (
|
) : tracksVersion ? (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t("vmLxc.updates.versionTrackingPendingShort")}
|
{aw.update_via === "docker"
|
||||||
|
? t("vmLxc.updates.updatedWithDockerImage")
|
||||||
|
: t("vmLxc.updates.versionTrackingPendingShort")}
|
||||||
</div>
|
</div>
|
||||||
) : hasCmd ? (
|
) : hasCmd ? (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
@@ -6009,9 +6110,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className={`text-xs text-muted-foreground leading-relaxed min-w-0 ${tracksVersion ? "mt-3" : ""}`}>
|
<p className={`text-xs text-muted-foreground leading-relaxed min-w-0 ${tracksVersion ? "mt-3" : ""}`}>
|
||||||
{managedByOs
|
{aw.update_via === "docker"
|
||||||
? t("vmLxc.updates.managedByOsPackages")
|
? t("vmLxc.updates.updatedWithDockerImage")
|
||||||
: t("vmLxc.updates.noUpdateMethodBody")}
|
: managedByOs
|
||||||
|
? t("vmLxc.updates.managedByOsPackages")
|
||||||
|
: t("vmLxc.updates.noUpdateMethodBody")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1324,6 +1324,7 @@
|
|||||||
"osPlusApps": "OS + Apps updates",
|
"osPlusApps": "OS + Apps updates",
|
||||||
"noUpdateMethodTitle": "No update method available",
|
"noUpdateMethodTitle": "No update method available",
|
||||||
"noUpdateMethodBody": "No update method has been identified for this application. Add a custom update command.",
|
"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",
|
"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.",
|
"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",
|
"hideNoticeButton": "Hide this notice for this app",
|
||||||
@@ -1560,6 +1561,11 @@
|
|||||||
"searchingApplications": "Searching applications…",
|
"searchingApplications": "Searching applications…",
|
||||||
"noApplicationsDetected": "No applications were detected.",
|
"noApplicationsDetected": "No applications were detected.",
|
||||||
"noNewApplicationsDetected": "No new 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.",
|
"oneNewApplicationDetected": "One new application was detected.",
|
||||||
"newApplicationsDetected": "{count} new applications were detected.",
|
"newApplicationsDetected": "{count} new applications were detected.",
|
||||||
"detectionFailed": "Application detection failed",
|
"detectionFailed": "Application detection failed",
|
||||||
|
|||||||
@@ -6693,6 +6693,20 @@ def get_proxmox_vms():
|
|||||||
)
|
)
|
||||||
if docker_registered and docker_inventory:
|
if docker_registered and docker_inventory:
|
||||||
vm_data['docker_inventory'] = 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
|
# PVE's cluster resources API reports disk=0 for most
|
||||||
# QEMU VMs — it can't see inside the guest filesystem
|
# QEMU VMs — it can't see inside the guest filesystem
|
||||||
@@ -13390,6 +13404,9 @@ def api_vm_apps_get(vmid):
|
|||||||
import lxc_apps
|
import lxc_apps
|
||||||
sidecar = lxc_apps.load_sidecar(vmid)
|
sidecar = lxc_apps.load_sidecar(vmid)
|
||||||
payload = sidecar if sidecar else {'vmid': vmid, 'apps': []}
|
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)
|
return jsonify(payload)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|||||||
+523
-47
@@ -185,6 +185,22 @@ _DOCKER_MANIFEST_ACCEPT = ", ".join((
|
|||||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||||
"application/vnd.docker.distribution.manifest.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_lock = threading.RLock()
|
||||||
_docker_inventory_cache: dict[str, dict] = {}
|
_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/._-)")
|
return _err("helper_slug must be a lowercase slug (letters/digits/._-)")
|
||||||
conf["helper_slug"] = hs
|
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
|
# Optional user-defined update command. Freeform bash that runs
|
||||||
# under `pct exec vmid -- sh -c "$command"` when the user hits
|
# under `pct exec vmid -- sh -c "$command"` when the user hits
|
||||||
# "Apply {app} update" from the Updates tab. This is deliberately
|
# "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
|
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]]:
|
def _docker_version_from_image_inspect(parsed: dict, inspected: dict) -> tuple[Optional[str], Optional[str]]:
|
||||||
"""Resolve an installed image version from local, immutable evidence."""
|
"""Resolve an installed image version from local, immutable evidence."""
|
||||||
direct_tag = _normalise_docker_display_version(parsed.get("tag"))
|
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 {})
|
labels = ((inspected.get("Config") or {}).get("Labels") or {})
|
||||||
# Application-specific labels take precedence over OCI labels because a
|
# Application-specific labels take precedence over OCI labels because a
|
||||||
# few publishers put the base distribution version in the latter.
|
# few publishers put the base distribution version in the latter.
|
||||||
for key in ("Version", "version"):
|
version, source = _docker_version_from_labels(labels, ("Version", "version"))
|
||||||
version = _normalise_docker_display_version(labels.get(key))
|
if version:
|
||||||
if version:
|
return version, source
|
||||||
return version, f"image_label:{key}"
|
|
||||||
|
|
||||||
# A moving tag often shares an image ID with an explicit release tag
|
# A moving tag often shares an image ID with an explicit release tag
|
||||||
# already present locally (for example frigate:stable + frigate:0.17.2).
|
# 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)
|
alternate_versions.sort(key=_docker_tag_semver_key, reverse=True)
|
||||||
return alternate_versions[0], "local_equivalent_tag"
|
return alternate_versions[0], "local_equivalent_tag"
|
||||||
|
|
||||||
version = _normalise_docker_display_version(labels.get("org.opencontainers.image.version"))
|
return _docker_version_from_labels(labels, ("org.opencontainers.image.version",))
|
||||||
if version:
|
|
||||||
return version, "image_label:org.opencontainers.image.version"
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def _docker_hub_version_for_digest(records: list[dict], digest: Optional[str]) -> Optional[str]:
|
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
|
return params
|
||||||
|
|
||||||
|
|
||||||
def _registry_digest_request(url: str, headers: dict) -> tuple[Optional[str], Optional[str]]:
|
class _DockerNoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
req = urllib.request.Request(url, headers=headers, method="HEAD")
|
"""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:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=_DOCKER_REGISTRY_TIMEOUT_SEC) as response:
|
with urllib.request.urlopen(token_req, timeout=_DOCKER_REGISTRY_TIMEOUT_SEC) as response:
|
||||||
return response.headers.get("Docker-Content-Digest"), None
|
token_payload = json.loads(response.read().decode("utf-8"))
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
if exc.code != 401:
|
return None, f"registry HTTP {exc.code}"
|
||||||
return None, f"registry HTTP {exc.code}"
|
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
||||||
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 network error: {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]]:
|
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]:
|
def _parse_compose_depends_on(value: Any) -> list[str]:
|
||||||
"""Return service names from Compose's ``depends_on`` label.
|
"""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]:
|
def _aggregate_docker_compose_projects(images: list[dict]) -> list[dict]:
|
||||||
"""Merge per-image Compose targets into one safe action per project.
|
"""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"),
|
"logo_url": display_meta.get("logo_url"),
|
||||||
"installed_version": installed_version,
|
"installed_version": installed_version,
|
||||||
"installed_version_source": installed_version_source,
|
"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": None,
|
||||||
|
"available_version_source": None,
|
||||||
"update_available": None,
|
"update_available": None,
|
||||||
"error": 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"))
|
available_version = _docker_hub_version_for_digest(records, item.get("remote_digest"))
|
||||||
if available_version and available_version != item.get("installed_version"):
|
if available_version and available_version != item.get("installed_version"):
|
||||||
item["available_version"] = available_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 {
|
return {
|
||||||
"vmid": int(vmid),
|
"vmid": int(vmid),
|
||||||
@@ -3181,7 +3559,12 @@ def partition_scheduled_release_targets(
|
|||||||
eligible_apps: list[dict] = []
|
eligible_apps: list[dict] = []
|
||||||
for app in apps or []:
|
for app in apps or []:
|
||||||
app_id = str(app.get("id") or "").strip()
|
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
|
continue
|
||||||
if not select_all_apps and app_id not in selected_app_ids:
|
if not select_all_apps and app_id not in selected_app_ids:
|
||||||
continue
|
continue
|
||||||
@@ -3361,6 +3744,11 @@ def _fire_update_notification(vmid, app: dict) -> None:
|
|||||||
return
|
return
|
||||||
if app.get("helper_slug") == "docker":
|
if app.get("helper_slug") == "docker":
|
||||||
return
|
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:
|
try:
|
||||||
from notification_manager import notification_manager
|
from notification_manager import notification_manager
|
||||||
import socket
|
import socket
|
||||||
@@ -3428,12 +3816,19 @@ def _docker_stack_notification_payload(
|
|||||||
lines.append(f'• Docker Engine: {installed} → {latest}')
|
lines.append(f'• Docker Engine: {installed} → {latest}')
|
||||||
for image in pending_images[:12]:
|
for image in pending_images[:12]:
|
||||||
reference = image.get('reference') or 'Docker image'
|
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')
|
installed = image.get('installed_version')
|
||||||
available = image.get('available_version')
|
available = image.get('available_version')
|
||||||
if installed and available and installed != available:
|
if installed and available and installed != available:
|
||||||
lines.append(f'• {reference}: {installed} → {available}')
|
lines.append(f'• {label}: {installed} → {available}')
|
||||||
else:
|
else:
|
||||||
lines.append(f'• {reference}: new registry digest')
|
lines.append(f'• {label}: new registry digest')
|
||||||
if len(pending_images) > 12:
|
if len(pending_images) > 12:
|
||||||
lines.append(f'• +{len(pending_images) - 12} additional image update(s)')
|
lines.append(f'• +{len(pending_images) - 12} additional image update(s)')
|
||||||
return {
|
return {
|
||||||
@@ -3515,6 +3910,14 @@ def _detect_with_alt_healing(vmid, app: dict) -> tuple:
|
|||||||
slug = app.get("helper_slug")
|
slug = app.get("helper_slug")
|
||||||
hint = (_fetch_tracking_hints() or {}).get(slug) or {}
|
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
|
# A modern Community Scripts marker (/root/.<app>) is a useful
|
||||||
# fallback, but it is not a live process probe. It can stay behind when
|
# 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
|
# 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"),
|
"id": app.get("id"),
|
||||||
"name": app.get("name"),
|
"name": app.get("name"),
|
||||||
"installed_via": app.get("installed_via"),
|
"installed_via": app.get("installed_via"),
|
||||||
|
"update_via": app.get("update_via"),
|
||||||
|
"container_name": app.get("container_name"),
|
||||||
"ports": app.get("ports") or [],
|
"ports": app.get("ports") or [],
|
||||||
# Keep the application-level logo in the compact /api/vms
|
# Keep the application-level logo in the compact /api/vms
|
||||||
# projection. Consumers can prefer a per-link logo and fall
|
# projection. Consumers can prefer a per-link logo and fall
|
||||||
@@ -4358,6 +4763,24 @@ def _probe_listening_ports(vmid) -> list[int]:
|
|||||||
return result
|
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:
|
def _docker_service_catalog_meta(service: str, container: str, image: str) -> dict:
|
||||||
"""Best-effort display metadata for a Docker workload.
|
"""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]
|
image_base = image.split("@", 1)[0].rsplit("/", 1)[-1].split(":", 1)[0]
|
||||||
raw_candidates = [service, container, image_base]
|
raw_candidates = [service, container, image_base]
|
||||||
candidates: list[str] = []
|
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:
|
for raw in raw_candidates:
|
||||||
candidate = re.sub(r"[^a-z0-9._-]+", "-", str(raw or "").strip().lower()).strip("-._")
|
candidate = re.sub(r"[^a-z0-9._-]+", "-", str(raw or "").strip().lower()).strip("-._")
|
||||||
if candidate and candidate not in candidates:
|
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 []
|
docker_web_links = _probe_docker_web_links(vmid) if docker_host_detected else []
|
||||||
extras: list = []
|
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):
|
for det_slug in sorted(detected_map):
|
||||||
if slug and det_slug == slug:
|
if slug and det_slug == slug:
|
||||||
continue
|
continue
|
||||||
@@ -4707,6 +5143,45 @@ def get_suggestions(vmid, force: bool = False) -> dict:
|
|||||||
for d in matched_detectors
|
for d in matched_detectors
|
||||||
)
|
)
|
||||||
if all_docker:
|
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
|
continue
|
||||||
det_tracking = dict(det_hint)
|
det_tracking = dict(det_hint)
|
||||||
if working:
|
if working:
|
||||||
@@ -4779,5 +5254,6 @@ def get_suggestions(vmid, force: bool = False) -> dict:
|
|||||||
# get_catalog_entry so the Register button pre-selects it.
|
# get_catalog_entry so the Register button pre-selects it.
|
||||||
"category": suggest_category_for(slug),
|
"category": suggest_category_for(slug),
|
||||||
"extras": extras,
|
"extras": extras,
|
||||||
|
"docker_workloads": sorted(docker_workloads, key=lambda item: item["name"].lower()),
|
||||||
"docker_web_links": docker_web_links,
|
"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"])
|
||||||
@@ -248,6 +248,15 @@
|
|||||||
"installed_via": "file",
|
"installed_via": "file",
|
||||||
"file_path": "/root/.vaultwarden",
|
"file_path": "/root/.vaultwarden",
|
||||||
"file_regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
"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",
|
"distribution": "searxng",
|
||||||
"installed_regex": "(\\d+\\.\\d+\\.\\d+\\+[0-9a-f]+)"
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user