Merge pull request #338 from MacRimi/fix/pr337-docker-update-hardening

Fix Docker app version tracking, cache consistency and delegated updates
This commit is contained in:
MacRimi
2026-09-05 17:02:08 +02:00
committed by GitHub
17 changed files with 668 additions and 92 deletions
+7 -16
View File
@@ -233,9 +233,8 @@ interface Suggestions {
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.
// A registrable application inside Docker. Its image remains the updater;
// registration adds identity and version tracking, not a second update path.
interface DockerWorkload {
slug: string
name: string
@@ -636,10 +635,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
)
// Detections the user hid and could restore from the Register-a-
// different-app panel. Not affected by registration state.
const hiddenDetections = detectedList.filter((d) => dismissedSlugs.has(d.slug))
const hiddenDetections = [...detectedList, ...(suggestions?.docker_workloads || [])]
.filter((d, index, items) => dismissedSlugs.has(d.slug) && items.findIndex(item => item.slug === d.slug) === index)
const searchInstalledApplications = async () => {
const before = new Set(visibleDetected.map((item) => item.slug))
const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug))
setSearchingApplications(true)
setDetectionNotice(null)
setError(null)
@@ -653,6 +653,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
const detected = new Set<string>()
if (result.helper_slug) detected.add(result.helper_slug)
for (const item of result.extras || []) detected.add(item.slug)
for (const item of result.docker_workloads || []) detected.add(item.slug)
const visible = [...detected].filter(
(slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug),
)
@@ -664,16 +665,6 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
found: true,
text: t("vmLxc.appEditor.newApplicationsDetected", { count: newCount }),
})
} else if ((result.docker_workloads || []).length > 0) {
// Saying "nothing found" while the panel is listing containerised
// applications it just read versions from is the one answer that is
// certainly wrong.
setDetectionNotice({
found: true,
text: t("vmLxc.appEditor.dockerDetectedWithWorkloads", {
count: (result.docker_workloads || []).length,
}),
})
} else {
setDetectionNotice({
found: false,
@@ -2361,7 +2352,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
size="sm"
variant="ghost"
onClick={() => dismissDetection(w.slug, w.name)}
aria-label={`Hide ${w.name} detection`}
aria-label={`${t("vmLxc.appEditor.hideButton")}: ${w.name}`}
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"
>
+38 -14
View File
@@ -100,6 +100,8 @@ interface LxcAppWatch {
// once, through the image.
docker_available_version?: string | null
docker_update_available?: boolean | null
docker_image_reference?: string | null
docker_binding_error?: string | null
ports?: LxcAppPort[]
logo_url?: string | null
health_path?: string | null
@@ -263,7 +265,7 @@ function hasLxcPendingUpdates(vm: VMData): boolean {
if (vm.type !== "lxc") return false
const osUpdates = vm.update_check?.count ?? 0
const appUpdates = (vm.app_watches || []).filter(
(app) => app.update_available === true && !app.exclude_from_badge,
(app) => app.update_via !== "docker" && app.update_available === true && !app.exclude_from_badge,
).length
const dockerRegistered = (vm.app_watches || []).some((app) => app.helper_slug === "docker")
const dockerUpdates = dockerRegistered ? (vm.docker_inventory?.update_count ?? 0) : 0
@@ -273,9 +275,9 @@ function hasLxcPendingUpdates(vm: VMData): boolean {
// 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(
const delegatedUpdates = dockerRegistered ? 0 : new Set((vm.app_watches || []).filter(
(app) => app.update_via === "docker" && app.docker_update_available === true && !app.exclude_from_badge,
).length
).map(app => app.docker_image_reference || app.id)).size
return osUpdates + appUpdates + dockerUpdates + delegatedUpdates > 0
}
@@ -2572,15 +2574,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const getAggregateUpdateCheck = (vm: VMData): LxcUpdateCheck | undefined => {
const uc = vm.update_check
const appCount = (vm.app_watches || []).filter(
(a) => a.update_available === true && !a.exclude_from_badge,
(a) => a.update_via !== "docker" && a.update_available === true && !a.exclude_from_badge,
).length
const dockerRegistered = (vm.app_watches || []).some((a) => a.helper_slug === "docker")
const dockerCount = dockerRegistered ? (vm.docker_inventory?.update_count ?? 0) : 0
// See hasLxcPendingUpdates: a delegated app counts only while its image
// is not already being counted through the Docker section.
const delegatedCount = dockerRegistered ? 0 : (vm.app_watches || []).filter(
const delegatedCount = dockerRegistered ? 0 : new Set((vm.app_watches || []).filter(
(a) => a.update_via === "docker" && a.docker_update_available === true && !a.exclude_from_badge,
).length
).map(app => app.docker_image_reference || app.id)).size
const osCount = uc?.count ?? 0
const total = osCount + appCount + dockerCount + delegatedCount
if (!uc && appCount === 0 && dockerCount === 0 && delegatedCount === 0) return undefined
@@ -2590,10 +2592,17 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// showed a number that explained nothing. Applications and images join
// that list under the same names they carry everywhere else.
const pendingNames: LxcPackageUpdate[] = []
const namedImages = new Set<string>()
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_via === "docker" && !isDelegatedPending) continue
if (a.update_available !== true && !isDelegatedPending) continue
if (isDelegatedPending) {
const reference = a.docker_image_reference || a.id
if (namedImages.has(reference)) continue
namedImages.add(reference)
}
pendingNames.push({
name: a.name || "",
current: a.installed_version || "",
@@ -5171,6 +5180,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const hasOsUpdates = osUpdateStatusKnown && !!uc.available
const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker")
const dockerRegistered = !!dockerAppWatch
const dockerWorkloadsRegistered = dockerRegistered || (selectedVM.app_watches || []).some(a => a.update_via === "docker")
const dockerEngineInstalledVersion = selectedVM.docker_inventory?.engine_version
|| dockerAppWatch?.installed_version
|| ""
@@ -5187,7 +5197,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|| canonicalDockerEngineUpdateCommand
const dockerInventoryRefreshing = selectedVM.docker_inventory?.refreshing === true
const dockerInventoryAvailable = selectedVM.docker_inventory?.available === true
const dockerImages = dockerRegistered ? (selectedVM.docker_inventory?.images || []) : []
const delegatedContainers = new Set((selectedVM.app_watches || [])
.filter(a => a.update_via === "docker").map(a => a.container_name))
const dockerImages = (selectedVM.docker_inventory?.images || []).filter(image =>
dockerRegistered || (image.used_by || []).some(name => delegatedContainers.has(name)))
const followedImageReferences = new Set(dockerImages.map(image => image.reference))
const dockerPending = dockerImages.filter((image) => image.update_available === true)
const helperExists = !!uc?.app_updater_present && uc?.helper_slug_source === "update_wrapper"
const helperName = uc?.helper_app_name || null
@@ -5250,7 +5264,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
: scheduleTargets.some((target) => versionTrackedScheduleAppIds.has(target))
const composeProjects = new Map<string, LxcDockerComposeTarget>()
for (const target of selectedVM.docker_inventory?.compose_projects || []) {
composeProjects.set(`docker-compose:${target.project}`, target)
if (dockerRegistered || dockerImages.some(image => (image.update_targets || []).some(item => item.project === target.project))) {
composeProjects.set(`docker-compose:${target.project}`, target)
}
}
const standaloneContainers = new Set<string>()
for (const image of dockerImages) {
@@ -5315,8 +5331,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
logoUrl: webLinkLogo || app.logo_url?.trim() || "",
}
})
const dockerUpdateUnits = dockerRegistered
? (selectedVM.docker_inventory?.update_units || [])
const dockerUpdateUnits = dockerWorkloadsRegistered
? (selectedVM.docker_inventory?.update_units || []).filter(unit =>
dockerRegistered || (unit.references || []).some(ref => followedImageReferences.has(ref)))
: []
const bulkActionChoices = [
...bulkAppChoices,
@@ -5513,8 +5530,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
standalone containers. Keep all three in
one registered-app section while exposing
a concrete action for each target. */}
{dockerRegistered && dockerAppWatch && (
{dockerWorkloadsRegistered && (
<div className={dockerEditing ? "py-4 -mx-4 px-4 bg-accent [&_textarea]:bg-background" : "py-4"}>
{dockerAppWatch && (<>
<div className="flex items-center justify-between gap-3 mb-3 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<Container className="h-4 w-4 text-muted-foreground flex-shrink-0" />
@@ -5649,8 +5667,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
</div>
)}
</>)}
{selectedVM.docker_inventory && (<>
<div className="border-t border-border/50 pt-4 mb-1 flex items-center justify-between gap-3">
<div id={`docker-images-${selectedVM.vmid}`} className="border-t border-border/50 pt-4 mb-1 flex items-center justify-between gap-3">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
{t("vmLxc.updates.dockerImagesSubheading")}
</div>
@@ -5985,7 +6004,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{aw.name}
</h3>
</div>
{!editing && (
{!editing && aw.update_via !== "docker" && (
<button
type="button"
onClick={() => openCustomCmdEditor(aw)}
@@ -6074,7 +6093,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
) : tracksVersion ? (
<div className="text-sm text-muted-foreground">
{aw.update_via === "docker"
? t("vmLxc.updates.updatedWithDockerImage")
? <>{aw.docker_binding_error
? t("vmLxc.updates.dockerBindingUnavailable")
: t("vmLxc.updates.updatedWithDockerImage")}{" "}
<a className="text-blue-400 hover:underline" href={`#docker-images-${selectedVM.vmid}`}>
{t("vmLxc.updates.dockerImagesSubheading")}
</a></>
: t("vmLxc.updates.versionTrackingPendingShort")}
</div>
) : hasCmd ? (
+11
View File
@@ -39,6 +39,17 @@ function withObservedStates(vmid: number, sidecar: any): any {
const apps = sidecar.apps.map((app: any) => {
const observed = observations.get(app.id)
if (!observed || observed.managed_oci_app_id) return app
if (observed.state_revision && sidecar._revision && observed.state_revision < sidecar._revision) return app
// Docker metadata can finish after the app's version probe. It arrives
// on the same VM feed but must not wait for a new sidecar revision.
if (app.update_via === "docker" && observed.update_via === "docker" && app.container_name === observed.container_name) {
for (const field of ["docker_available_version", "docker_update_available", "docker_image_reference", "docker_binding_error"]) {
if (field in observed && observed[field] !== app[field]) {
app = { ...app, [field]: observed[field] }
changed = true
}
}
}
if (observed.state_revision && sidecar._revision) {
if (observed.state_revision < sidecar._revision) return app
} else if (!observed.checked_at || (app.state?.checked_at && observed.checked_at < app.state.checked_at)) {
+7
View File
@@ -1239,6 +1239,8 @@
"custom": "Benutzerdefinierte Cron…"
},
"updates": {
"updatedWithDockerImage": "Wird zusammen mit dem Docker-Image aktualisiert.",
"dockerBindingUnavailable": "Noch kein Image zugeordnet. Prüfen Sie den Containernamen und aktualisieren Sie das Image-Inventar.",
"osPackagesTitle": "Betriebssystempakete",
"lastCheckedPrefix": "Zuletzt überprüft:",
"familyLabel": "Familie:",
@@ -1400,6 +1402,11 @@
"loadFailed": "Die Massenaktualisierungskonfiguration konnte nicht geladen werden."
},
"appEditor": {
"dockerDetectedWithWorkloads": "Docker mit {count} Anwendungen in Containern erkannt",
"dockerWorkloadsHeading": "Anwendungen in Docker",
"runsInsideDocker": "Wird mit dem Docker-Image aktualisiert",
"upstreamDelegatedTitle": "Die verfügbare Version stammt aus dem Docker-Image",
"upstreamDelegatedHelp": "Diese Anwendung läuft in einem Container. Die verfügbare Version wird aus dessen Image ermittelt, ohne separate Abfrage oder doppelte Benachrichtigungen. Aktualisieren Sie das Image im Tab Aktualisierungen.",
"closePanel": "Panel schließen",
"cancelButton": "Stornieren",
"saveButton": "Speichern",
+1
View File
@@ -1325,6 +1325,7 @@
"noUpdateMethodTitle": "No update method available",
"noUpdateMethodBody": "No update method has been identified for this application. Add a custom update command.",
"updatedWithDockerImage": "Updated with its Docker image.",
"dockerBindingUnavailable": "No image is linked yet. Check the container name and refresh the image inventory.",
"ociImmutableTitle": "OCI image container",
"ociImmutableBody": "OS packages are baked in at image build time and cannot be updated in place. Apply updates manually or reinstall with a newer image.",
"hideNoticeButton": "Hide this notice for this app",
+7
View File
@@ -1239,6 +1239,8 @@
"custom": "Cron personalizado..."
},
"updates": {
"updatedWithDockerImage": "Se actualiza con su imagen Docker.",
"dockerBindingUnavailable": "Aún no hay una imagen vinculada. Comprueba el nombre del contenedor y actualiza el inventario de imágenes.",
"osPackagesTitle": "Paquetes de sistema operativo",
"lastCheckedPrefix": "Última comprobación:",
"familyLabel": "Familia:",
@@ -1400,6 +1402,11 @@
"loadFailed": "no se pudo cargar la configuración de actualización masiva."
},
"appEditor": {
"dockerDetectedWithWorkloads": "Docker detectado con {count} aplicaciones en contenedores",
"dockerWorkloadsHeading": "Aplicaciones en Docker",
"runsInsideDocker": "Se actualiza con su imagen Docker",
"upstreamDelegatedTitle": "La versión disponible proviene de su imagen Docker",
"upstreamDelegatedHelp": "Esta aplicación se ejecuta en un contenedor. La versión disponible se obtiene de su imagen, sin otra consulta independiente ni avisos duplicados. Actualízala desde su imagen en la pestaña Actualizaciones.",
"closePanel": "Cerrar panel",
"cancelButton": "Cancelar",
"saveButton": "Guardar",
+7
View File
@@ -1239,6 +1239,8 @@
"custom": "Cron personnalisé…"
},
"updates": {
"updatedWithDockerImage": "Mise à jour avec son image Docker.",
"dockerBindingUnavailable": "Aucune image associée pour le moment. Vérifiez le nom du conteneur et actualisez linventaire des images.",
"osPackagesTitle": "Packages de système d'exploitation",
"lastCheckedPrefix": "Dernière vérification :",
"familyLabel": "Famille:",
@@ -1400,6 +1402,11 @@
"loadFailed": "Impossible de charger la configuration de la mise à jour groupée."
},
"appEditor": {
"dockerDetectedWithWorkloads": "Docker détecté avec {count} applications conteneurisées",
"dockerWorkloadsHeading": "Applications dans Docker",
"runsInsideDocker": "Mise à jour avec son image Docker",
"upstreamDelegatedTitle": "La version disponible provient de son image Docker",
"upstreamDelegatedHelp": "Cette application sexécute dans un conteneur. La version disponible est déterminée par son image, sans vérification séparée ni notifications en double. Mettez son image à jour dans longlet Mises à jour.",
"closePanel": "Fermer le panneau",
"cancelButton": "Annuler",
"saveButton": "Sauvegarder",
+7
View File
@@ -1239,6 +1239,8 @@
"custom": "Cronologia personalizzata..."
},
"updates": {
"updatedWithDockerImage": "Si aggiorna con la sua immagine Docker.",
"dockerBindingUnavailable": "Nessuna immagine ancora associata. Controlla il nome del container e aggiorna linventario delle immagini.",
"osPackagesTitle": "Pacchetti del sistema operativo",
"lastCheckedPrefix": "Ultimo controllo:",
"familyLabel": "Famiglia:",
@@ -1400,6 +1402,11 @@
"loadFailed": "impossibile caricare la configurazione dell'aggiornamento collettivo."
},
"appEditor": {
"dockerDetectedWithWorkloads": "Docker rilevato con {count} applicazioni in container",
"dockerWorkloadsHeading": "Applicazioni in Docker",
"runsInsideDocker": "Si aggiorna con la sua immagine Docker",
"upstreamDelegatedTitle": "La versione disponibile proviene dalla sua immagine Docker",
"upstreamDelegatedHelp": "Questa applicazione viene eseguita in un container. La versione disponibile viene ricavata dalla sua immagine, senza controlli separati né notifiche duplicate. Aggiorna limmagine nella scheda Aggiornamenti.",
"closePanel": "Chiudi pannello",
"cancelButton": "Cancellare",
"saveButton": "Salva",
+7
View File
@@ -1239,6 +1239,8 @@
"custom": "Cronograma personalizado…"
},
"updates": {
"updatedWithDockerImage": "Atualizada através da sua imagem Docker.",
"dockerBindingUnavailable": "Ainda não existe uma imagem associada. Verifique o nome do contentor e atualize o inventário de imagens.",
"osPackagesTitle": "Pacotes de sistema operacional",
"lastCheckedPrefix": "Última verificação:",
"familyLabel": "Família:",
@@ -1400,6 +1402,11 @@
"loadFailed": "não foi possível carregar a configuração de atualização em massa."
},
"appEditor": {
"dockerDetectedWithWorkloads": "Docker detetado com {count} aplicações em contentores",
"dockerWorkloadsHeading": "Aplicações no Docker",
"runsInsideDocker": "Atualizada através da sua imagem Docker",
"upstreamDelegatedTitle": "A versão disponível provém da sua imagem Docker",
"upstreamDelegatedHelp": "Esta aplicação é executada num contentor. A versão disponível é obtida da sua imagem, sem uma consulta separada nem notificações duplicadas. Atualize a imagem no separador Atualizações.",
"closePanel": "Fechar painel",
"cancelButton": "Cancelar",
"saveButton": "Salvar",
+7
View File
@@ -1260,6 +1260,8 @@
"custom": "Vlastný cron…"
},
"updates": {
"updatedWithDockerImage": "Aktualizuje sa spolu so svojím obrazom Docker.",
"dockerBindingUnavailable": "Zatiaľ nie je priradený žiadny obraz. Skontrolujte názov kontajnera a obnovte inventár obrazov.",
"osPackagesTitle": "Balíky systému",
"lastCheckedPrefix": "Naposledy skontrolované:",
"familyLabel": "Systém:",
@@ -1421,6 +1423,11 @@
"loadFailed": "Konfiguráciu hromadnej aktualizácie sa nepodarilo načítať."
},
"appEditor": {
"dockerDetectedWithWorkloads": "Zistený Docker s {count} aplikáciami v kontajneroch",
"dockerWorkloadsHeading": "Aplikácie v Dockeri",
"runsInsideDocker": "Aktualizuje sa spolu s obrazom Docker",
"upstreamDelegatedTitle": "Dostupná verzia pochádza z jej obrazu Docker",
"upstreamDelegatedHelp": "Táto aplikácia beží v kontajneri. Dostupná verzia sa zisťuje z jeho obrazu bez samostatnej kontroly alebo duplicitných upozornení. Obraz aktualizujte na karte Aktualizácie.",
"closePanel": "Zavrieť panel",
"cancelButton": "Zrušiť",
"saveButton": "Uložiť",
+7
View File
@@ -1239,6 +1239,8 @@
"custom": "Anpassad cron..."
},
"updates": {
"updatedWithDockerImage": "Uppdateras med sin Docker-avbildning.",
"dockerBindingUnavailable": "Ingen avbildning är kopplad ännu. Kontrollera containerns namn och uppdatera avbildningsinventeringen.",
"osPackagesTitle": "OS-paket",
"lastCheckedPrefix": "Senast kontrollerad:",
"familyLabel": "Familj:",
@@ -1400,6 +1402,11 @@
"loadFailed": "Det gick inte att läsa in massuppdateringskonfigurationen."
},
"appEditor": {
"dockerDetectedWithWorkloads": "Docker hittades med {count} applikationer i containrar",
"dockerWorkloadsHeading": "Applikationer i Docker",
"runsInsideDocker": "Uppdateras med sin Docker-avbildning",
"upstreamDelegatedTitle": "Den tillgängliga versionen hämtas från dess Docker-avbildning",
"upstreamDelegatedHelp": "Den här applikationen körs i en container. Den tillgängliga versionen hämtas från dess avbildning, utan separat kontroll eller dubbla aviseringar. Uppdatera avbildningen på fliken Uppdateringar.",
"closePanel": "Stäng panelen",
"cancelButton": "Avbryt",
"saveButton": "Spara",
+23 -11
View File
@@ -6691,8 +6691,11 @@ def get_proxmox_vms():
for app in app_list
if isinstance(app, dict)
)
if docker_registered and docker_inventory:
vm_data['docker_inventory'] = docker_inventory
if docker_inventory and (docker_registered or any(
item.get('update_via') == 'docker' for item in app_list
)):
import lxc_apps as _docker_apps
vm_data['docker_inventory'] = _docker_apps.docker_inventory_for_apps(app_list, 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.
@@ -13404,8 +13407,6 @@ def api_vm_apps_get(vmid):
import lxc_apps
sidecar = lxc_apps.load_sidecar(vmid)
payload = sidecar if sidecar else {'vmid': vmid, 'apps': []}
# Read the shared, file-versioned snapshot; annotate only on the way
# out so asynchronously collected Docker metadata remains current.
lxc_apps.annotate_delegated_apps(payload.get('apps') or [], _get_lxc_docker_inventory_map().get(str(vmid)))
return jsonify(payload)
except Exception as e:
@@ -21708,10 +21709,10 @@ def _compose_scheduled_update_command(vmid: int, target: str, targets: list[str]
for item in targets
if item.startswith("docker-compose:")
}
docker_registered = any(a.get("helper_slug") == "docker" for a in apps)
docker_registered = any(a.get("helper_slug") == "docker" or a.get('update_via') == 'docker' for a in apps)
if selected_projects and docker_registered:
try:
inventory = lxc_apps.get_docker_inventory(vmid, force=True)
inventory = lxc_apps.docker_inventory_for_apps(apps, lxc_apps.get_docker_inventory(vmid, force=True))
except Exception:
inventory = {}
commands_by_project: dict[str, str] = {}
@@ -21868,14 +21869,14 @@ def _resolve_bulk_update_plan(vmid: int, targets: list[str]) -> dict:
labels.append(label)
if docker_unit_ids:
if not docker_app:
if not docker_app and not any(app.get('update_via') == 'docker' for app in apps):
unavailable.extend({
'target': target_id,
'reason': 'Docker is no longer registered',
} for target_id in sorted(docker_unit_ids))
else:
try:
inventory = lxc_apps.get_docker_inventory(vmid, force=True)
inventory = lxc_apps.docker_inventory_for_apps(apps, lxc_apps.get_docker_inventory(vmid, force=True))
except Exception as exc:
inventory = {'available': False, 'error': str(exc), 'update_units': []}
if not inventory.get('available'):
@@ -22098,11 +22099,22 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
targets = list(dict.fromkeys(filtered_targets))
if any(value.startswith("docker-") for value in targets):
docker_registered = any(app.get("helper_slug") == "docker" for app in registered_apps)
delegated = any(app.get('update_via') == 'docker' for app in registered_apps)
if not docker_registered:
unavailable_docker = [value for value in targets if value.startswith('docker-')]
allowed = set()
if delegated:
import lxc_apps
inventory = lxc_apps.docker_inventory_for_apps(
registered_apps, lxc_apps.get_docker_inventory(vmid, force=True))
if inventory.get('available'):
allowed.update(f"docker-compose:{p['project']}" for p in inventory.get('compose_projects') or [])
allowed.update(f"docker-container:{name}" for image in inventory.get('images') or []
for name in image.get('standalone_containers') or [])
unavailable_docker = [value for value in targets if value.startswith('docker-') and value not in allowed]
deferred_targets.extend(unavailable_docker)
targets = [value for value in targets if not value.startswith("docker-")]
reasons.append('Docker targets are no longer registered')
targets = [value for value in targets if value not in unavailable_docker]
if unavailable_docker:
reasons.append('Docker targets are no longer registered')
if not targets:
return finish('skipped', 'app', [])
has_os_target = "os" in targets
+205 -43
View File
@@ -201,6 +201,12 @@ _DOCKER_IMAGE_CONFIG_MEDIA_TYPES = {
}
_docker_remote_config_lock = threading.RLock()
_docker_remote_config_cache: dict[tuple, dict] = {}
_docker_remote_config_flights = [threading.Lock() for _ in range(16)]
# Optional labels must never hold up the usable local/digest inventory.
_docker_metadata_pool = concurrent.futures.ThreadPoolExecutor(max_workers=4)
_docker_metadata_slots = threading.BoundedSemaphore(64)
_docker_metadata_context = threading.local()
_docker_slug_index_cache = (None, {})
_docker_inventory_lock = threading.RLock()
_docker_inventory_cache: dict[str, dict] = {}
@@ -1223,6 +1229,8 @@ def validate_config(payload: dict) -> tuple[bool, Any]:
if method == "helper" and (not hs or hs in ("docker", "adguard")):
return _err("a supported helper_slug is required for update_method=helper")
conf["update_method"] = method
if conf.get("update_via") == "docker" and method != "none":
return _err("Docker-delegated apps update through their image, not a separate app updater")
# Optional per-app dismiss flag for the "no update method defined"
# notice shown in the Updates tab. Only affects the notice card;
@@ -1400,9 +1408,19 @@ def detect_installed_version(vmid, config: dict) -> tuple[Optional[str], Optiona
return version, None
if method == "docker_label":
# docker inspect --format '{{index .Config.Labels "<label>"}}' <container>
fmt = '{{index .Config.Labels "' + config["label"] + '"}}'
rc, out, err = _pct_exec(vmid, ["docker", "inspect", "--format", fmt, config["container_name"]])
if config.get('update_via') == 'docker':
# A protected recreation preserves user container labels. Those
# can include the previous image's version, so delegated apps
# must read the immutable image actually used by the container.
rc, image_id, err = _pct_exec(vmid, ["docker", "inspect", "--format", "{{.Image}}", config["container_name"]])
image_id = image_id.strip()
if rc != 0 or not re.fullmatch(r'sha256:[a-f0-9]{64}', image_id):
return None, "could not resolve the container's installed image"
argv = ["docker", "image", "inspect", "--format", fmt, image_id]
else:
argv = ["docker", "inspect", "--format", fmt, config["container_name"]]
rc, out, err = _pct_exec(vmid, argv)
if rc != 0:
return None, (err or out).strip()[:200] or "docker inspect failed"
text = (out or "").strip()
@@ -2003,6 +2021,16 @@ def _normalise_docker_container_reference(reference: str) -> str:
return reference
def _docker_reference_identity(reference: str) -> Optional[tuple]:
"""Canonical mutable tag, not another alias sharing the same image ID."""
reference = _normalise_docker_container_reference(reference)
if not reference or '@' in reference or reference.startswith('sha256:'):
return None
repository, tag = reference.rsplit(':', 1)
parsed = _parse_docker_reference(repository, tag)
return (parsed['registry'], parsed['repository'], parsed['tag']) if parsed else None
def _parse_bearer_challenge(value: str) -> Optional[dict]:
if not isinstance(value, str) or not value.lower().startswith("bearer "):
return None
@@ -2045,23 +2073,38 @@ def _registry_bearer_token(challenge: dict) -> tuple[Optional[str], Optional[str
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"))
with urllib.request.urlopen(token_req, timeout=_docker_registry_timeout()) as response:
body = response.read(65537)
if len(body) > 65536:
return None, "registry token response exceeded the size limit"
token_payload = json.loads(body.decode("utf-8"))
except urllib.error.HTTPError as exc:
return None, f"registry HTTP {exc.code}"
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
return None, f"registry network error: {exc}"
if not isinstance(token_payload, dict):
return None, "registry token response was not an object"
token = token_payload.get("token") or token_payload.get("access_token")
if not token:
if not isinstance(token, str) or not token:
return None, "registry token response was empty"
return token, None
def _docker_registry_timeout():
deadline = getattr(_docker_metadata_context, 'deadline', None)
if deadline is None:
return _DOCKER_REGISTRY_TIMEOUT_SEC
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError('registry metadata budget exhausted')
return min(_DOCKER_REGISTRY_TIMEOUT_SEC, remaining)
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:
with _docker_no_redirect_opener.open(req, timeout=_docker_registry_timeout()) as response:
body = None
if max_bytes > 0:
body = response.read(max_bytes + 1)
@@ -2125,6 +2168,8 @@ def _registry_request(url: str, headers: dict, method: str = "HEAD",
return None, None, token, error
if location:
return None, None, token, "registry redirected more than once"
if status == 401:
return None, None, token, "registry HTTP 401"
return response_headers, body, token, None
@@ -2163,6 +2208,8 @@ def _select_platform_manifest(index: dict, platform: dict) -> Optional[str]:
host, a pull would fail and there is no version to report.
"""
def _norm(entry: dict) -> tuple:
if not isinstance(entry, dict):
return '', '', ''
os_name = str(entry.get("os") or "").lower()
architecture = str(entry.get("architecture") or "").lower()
variant = str(entry.get("variant") or "").lower()
@@ -2177,7 +2224,8 @@ def _select_platform_manifest(index: dict, platform: dict) -> Optional[str]:
for entry in (index or {}).get("manifests") or []:
if not isinstance(entry, dict):
continue
if (entry.get("annotations") or {}).get("vnd.docker.reference.type"):
annotations = entry.get("annotations") or {}
if not isinstance(annotations, dict) or annotations.get("vnd.docker.reference.type"):
continue
candidate = _norm(entry.get("platform") or {})
if candidate[0] in ("", "unknown") or candidate[1] in ("", "unknown"):
@@ -2202,7 +2250,10 @@ def _registry_get_document(base: str, digest: str, headers: dict, token: Optiona
if not _verify_content_digest(body, digest):
return None, token, "remote manifest digest mismatch"
try:
return json.loads(body.decode("utf-8")), token, None
document = json.loads(body.decode("utf-8"))
if not isinstance(document, dict):
return None, token, "remote manifest was not an object"
return document, token, None
except (UnicodeDecodeError, json.JSONDecodeError):
return None, token, "remote manifest was not valid JSON"
@@ -2224,6 +2275,8 @@ def _remote_image_config_labels(parsed: dict, remote_digest: str, platform: dict
if error:
return None, error
config = manifest.get("config") or {}
if not isinstance(config, dict):
return None, "remote_unsupported_manifest"
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:")):
@@ -2243,7 +2296,12 @@ def _remote_image_config_labels(parsed: dict, remote_digest: str, platform: dict
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
if not isinstance(payload, dict) or not isinstance(payload.get("config"), dict):
return None, "remote config was not an image config object"
labels = payload['config'].get('Labels') or {}
if not isinstance(labels, dict) or any(not isinstance(v, str) for v in labels.values()):
return None, "remote labels were not a string map"
return labels, None
def _fetch_remote_image_config_labels(parsed: dict, remote_digest: str, platform: dict,
@@ -2253,18 +2311,27 @@ def _fetch_remote_image_config_labels(parsed: dict, remote_digest: str, platform
str(parsed.get("api_host") or ""),
str(parsed.get("repository") or ""),
str(remote_digest or ""),
str((platform or {}).get("os") or ""),
str((platform or {}).get("architecture") or ""),
str((platform or {}).get("variant") 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
with _docker_remote_config_flights[hash(cache_key) % len(_docker_remote_config_flights)]:
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")
try:
labels, error = _remote_image_config_labels(parsed, remote_digest, platform)
except Exception as exc:
return None, f"remote metadata unavailable: {type(exc).__name__}"
# Errors are retryable at the next explicit/lifecycle/daily refresh.
# Successful content is immutable because the key contains its digest.
if error is None:
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": None}
return labels, error
_DOCKER_AVAILABLE_VERSION_REASONS = {
@@ -2518,6 +2585,23 @@ def resolve_docker_image_for_app(app: dict, inventory: dict) -> dict:
return {"image_reference": None, "error": "container_not_in_inventory"}
def docker_inventory_for_apps(apps: list, inventory: dict) -> dict:
"""Expose only Docker workloads the user registered (or the whole Docker stack)."""
if not inventory or any(app.get('helper_slug') == 'docker' for app in apps):
return inventory or {}
references = {
image.get('reference') for image in inventory.get('images') or []
if any(app.get('update_via') == 'docker'
and app.get('container_name') in (image.get('used_by') or []) for app in apps)
}
images = [image for image in inventory.get('images') or [] if image.get('reference') in references]
return {**inventory, 'images': images,
'update_count': sum(image.get('update_available') is True for image in images),
'update_units': [unit for unit in inventory.get('update_units') or []
if references.intersection(unit.get('references') or [])],
'compose_projects': _aggregate_docker_compose_projects(images)}
def annotate_delegated_apps(apps: list, docker_inventory: dict) -> None:
"""Attach the image-resolved version to apps that delegate to Docker.
@@ -2526,7 +2610,7 @@ def annotate_delegated_apps(apps: list, docker_inventory: dict) -> None:
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:
if not apps:
return
try:
for app in apps:
@@ -2539,6 +2623,8 @@ def annotate_delegated_apps(apps: list, docker_inventory: dict) -> None:
app['docker_available_version'] = None
app['docker_update_available'] = None
link = resolve_docker_image_for_app(app, docker_inventory)
app['docker_image_reference'] = link.get('image_reference')
app['docker_binding_error'] = link.get('error')
reference = link.get('image_reference')
if not reference:
continue
@@ -2703,7 +2789,10 @@ def _docker_inventory_from_ct(vmid) -> dict:
raw_image_rows.append(tuple(part.strip() for part in parts))
inspected_images: dict[str, dict] = {}
unique_image_ids = list(dict.fromkeys(row[3] for row in raw_image_rows if row[3]))
unique_image_ids = list(dict.fromkeys([
*(row[3] for row in raw_image_rows if row[3]),
*(item['image_id'] for item in containers if item.get('image_id')),
]))
if unique_image_ids:
rc_image_inspect, image_inspect_out, _ = _pct_exec(
vmid,
@@ -2730,14 +2819,8 @@ def _docker_inventory_from_ct(vmid) -> dict:
seen.add(parsed["reference"])
used_by = sorted({
item["name"] for item in containers
if (
_normalise_docker_container_reference(str(item.get("image_reference") or item.get("image") or ""))
== parsed["reference"]
or str(item.get("image_id") or item.get("image") or "")
in (image_id, image_id.removeprefix("sha256:"))
or str(item.get("image_id") or "").removeprefix("sha256:")
== image_id.removeprefix("sha256:")
)
if _docker_reference_identity(str(item.get("image_reference") or item.get("image") or ""))
== (parsed['registry'], parsed['repository'], parsed['tag'])
})
if not used_by:
# Skip orphan images (no container — running or stopped —
@@ -2747,6 +2830,20 @@ def _docker_inventory_from_ct(vmid) -> dict:
# `docker image prune` / `docker rmi` outside of ProxMenux.
continue
used_containers = [item for item in containers if item.get("name") in used_by]
# A pull can move the local tag before its containers are recreated.
# Inspect the image actually used by the container, not that new tag.
running_ids = [str(item.get('image_id') or '') for item in used_containers if item.get('image_id')]
actual_id = next((value for value in running_ids if value != image_id), None)
if actual_id:
image_id = actual_id
digest = ''
actual_image = inspected_images.get(actual_id) or {}
for repo_digest in actual_image.get('RepoDigests') or []:
repository_name, _, candidate_digest = repo_digest.partition('@')
candidate = _parse_docker_reference(repository_name, tag)
if candidate and (candidate['registry'], candidate['repository']) == (parsed['registry'], parsed['repository']):
digest = candidate_digest
break
compose_targets: dict[str, dict] = {}
standalone_containers: list[str] = []
for container in used_containers:
@@ -2875,13 +2972,8 @@ def _docker_inventory_from_ct(vmid) -> dict:
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
for item in pending:
item["available_version_source"] = "remote_metadata_pending"
return {
"vmid": int(vmid),
@@ -2939,9 +3031,54 @@ def get_docker_inventory(vmid, force: bool = False) -> dict:
result = pending
with _docker_inventory_lock:
_docker_inventory_cache[key] = result
_queue_docker_metadata(key, result)
return dict(result)
def _queue_docker_metadata(key: str, snapshot: dict) -> None:
"""Enrich a published snapshot, never an inventory from a later CT boot.
Only a real inventory refresh schedules work. Reading a tab/cache does
not contact registries. Queue and concurrency are bounded globally.
"""
def enrich(index, item):
try:
_docker_metadata_context.deadline = time.monotonic() + 20
version, source = _docker_available_version_from_registry(item)
with _docker_inventory_lock:
if _docker_inventory_cache.get(key) is not snapshot:
return
images = list(snapshot['images'])
images[index] = {**images[index], 'available_version_source': source}
if version and version != item.get('installed_version'):
images[index]['available_version'] = version
snapshot['images'] = images
except Exception:
# Optional metadata cannot turn a successful digest scan into an error.
with _docker_inventory_lock:
if _docker_inventory_cache.get(key) is snapshot:
images = list(snapshot['images'])
images[index] = {**images[index], 'available_version_source': 'remote_fetch_error'}
snapshot['images'] = images
finally:
_docker_metadata_context.deadline = None
_docker_metadata_slots.release()
if not snapshot.get('available'):
return
for index, item in enumerate(snapshot.get('images') or []):
if item.get('available_version_source') != 'remote_metadata_pending':
continue
if not _docker_metadata_slots.acquire(blocking=False):
item['available_version_source'] = 'remote_metadata_deferred'
continue
try:
_docker_metadata_pool.submit(enrich, index, dict(item))
except Exception:
_docker_metadata_slots.release()
item['available_version_source'] = 'remote_metadata_deferred'
def mark_docker_inventory_refreshing(vmid) -> dict:
"""Publish a non-destructive lifecycle transition for one Docker CT.
@@ -3801,7 +3938,6 @@ def _docker_stack_notification_payload(
{
'reference': image.get('reference'),
'remote_digest': image.get('remote_digest'),
'available_version': image.get('available_version'),
}
for image in pending_images
],
@@ -3871,8 +4007,25 @@ def emit_all_pending_docker_stacks() -> int:
),
None,
)
if not docker_app or docker_app.get('notifications_enabled', True) is False:
continue
if docker_app:
if docker_app.get('notifications_enabled', True) is False:
continue
else:
# No automatic Docker registration: notify only images explicitly
# followed by delegated apps, honoring each app's notification choice.
references = {
resolve_docker_image_for_app(app, inventory).get('image_reference')
for app in sidecar.get('apps') or []
if app.get('update_via') == 'docker'
and app.get('notifications_enabled', True) is not False
} - {None}
if not references:
continue
inventory = {**inventory, 'images': [
image for image in inventory.get('images') or []
if image.get('reference') in references
]}
docker_app = {}
payload = _docker_stack_notification_payload(
vmid, docker_app, inventory, names.get(vmid) or f'CT-{vmid}',
)
@@ -3908,7 +4061,6 @@ def _detect_with_alt_healing(vmid, app: dict) -> tuple:
the app dict was rewritten.
"""
slug = app.get("helper_slug")
hint = (_fetch_tracking_hints() or {}).get(slug) or {}
# An app whose updates are delegated to its Docker image must keep its
# docker detector. Healing it onto a leftover /root/.<app> marker would
@@ -3918,6 +4070,8 @@ def _detect_with_alt_healing(vmid, app: dict) -> tuple:
version, error = detect_installed_version(vmid, app)
return version, error, False
hint = (_fetch_tracking_hints() or {}).get(slug) or {}
# A modern Community Scripts marker (/root/.<app>) is a useful
# fallback, but it is not a live process probe. It can stay behind when
# an operator upgrades an application outside the helper script. When a
@@ -4770,14 +4924,22 @@ def _docker_container_slug_index() -> dict:
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():
global _docker_slug_index_cache
hints = _fetch_tracking_hints()
with _docker_remote_config_lock:
previous, index = _docker_slug_index_cache
if previous is hints:
return index
index = {}
for slug, hint in (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)
with _docker_remote_config_lock:
_docker_slug_index_cache = (hints, index)
return index
@@ -125,10 +125,6 @@ class SchedulingExclusionTests(unittest.TestCase):
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."""
@@ -257,10 +257,6 @@ class RegistryRedirectTests(unittest.TestCase):
self.assertIsNone(error)
if __name__ == "__main__":
unittest.main()
class ContainerIdentityTests(unittest.TestCase):
"""A container's catalog identity comes from a detector that declares it."""
@@ -338,3 +334,7 @@ class UpdateNotificationWordingTests(unittest.TestCase):
"immich",
)
self.assertIn("• valkey/valkey:8-bookworm: new registry digest", payload["details"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,59 @@
// Exercise actual shared-cache and badge code, without a browser or API.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const root = path.resolve(__dirname, '../../AppImage')
const ts = require(path.join(root, 'node_modules/typescript'))
function compile(source, context) {
const result = ts.transpileModule(source, {compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX,
}, reportDiagnostics: true})
assert.equal(result.diagnostics.length, 0)
vm.runInNewContext(result.outputText, context)
return context.exports
}
let requests = 0
const cache = compile(fs.readFileSync(path.join(root, 'lib/lxc-apps-cache.ts'), 'utf8'), {
exports: {}, require: () => ({fetchApi: () => { requests++; throw new Error('unexpected HTTP') }}),
})
const app = {id: 'one', update_via: 'docker', container_name: 'example', state: {installed_version: '1.0'}}
cache.setLxcAppsCached(101, {_revision: 10, apps: [app]})
const watch = {...app, state_revision: 10, installed_version: '1.0', docker_image_reference: 'example:latest',
docker_available_version: '2.0', docker_update_available: true, docker_binding_error: null}
cache.syncLxcAppsState(101, [watch])
assert.equal(cache.getLxcAppsCached(101).sidecar.apps[0].docker_available_version, '2.0')
cache.syncLxcAppsState(101, [{...watch, docker_available_version: '2.1'}])
assert.equal(cache.getLxcAppsCached(101).sidecar.apps[0].docker_available_version, '2.1', 'metadata changes without a new app probe')
cache.syncLxcAppsState(101, [{...watch, docker_available_version: null, docker_update_available: null, docker_binding_error: 'inventory_unavailable'}])
assert.equal(cache.getLxcAppsCached(101).sidecar.apps[0].docker_available_version, null)
cache.setLxcAppsCached(101, {_revision: 11, apps: [{...app, container_name: 'replacement'}]})
cache.syncLxcAppsState(101, [watch])
assert.equal(cache.getLxcAppsCached(101).sidecar.apps[0].docker_available_version, undefined, 'old observation cannot decorate edited registration')
assert.equal(requests, 0)
const source = fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8')
const tree = ts.createSourceFile('vm.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const pieces = []
function walk(node) {
if (ts.isFunctionDeclaration(node) && node.name?.text === 'hasLxcPendingUpdates') pieces.push(node.getText(tree))
if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'getAggregateUpdateCheck') pieces.push(`const ${node.getText(tree)};`)
ts.forEachChild(node, walk)
}
walk(tree)
assert.equal(pieces.length, 2)
const badges = compile(pieces.join('\n') + '\nexports.aggregate = getAggregateUpdateCheck; exports.pending = hasLxcPendingUpdates;', {exports: {}})
const guest = {type: 'lxc', app_watches: [{...watch, name: 'First'}, {...watch, id: 'two', name: 'Second'}]}
assert.equal(badges.pending(guest), true)
assert.equal(badges.aggregate(guest).count, 1, 'two apps in one image count once')
assert.equal(badges.aggregate(guest).packages.length, 1)
guest.app_watches.push({id: 'engine', helper_slug: 'docker'})
guest.docker_inventory = {update_count: 1, images: [{reference: 'example:latest', update_available: true}]}
assert.equal(badges.aggregate(guest).count, 1, 'registering Engine does not double count image')
for (const locale of ['en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv']) {
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`)))
for (const key of ['updatedWithDockerImage', 'dockerBindingUnavailable']) assert.equal(typeof messages.vmLxc.updates[key], 'string')
for (const key of ['dockerWorkloadsHeading', 'runsInsideDocker', 'upstreamDelegatedTitle', 'upstreamDelegatedHelp']) assert.equal(typeof messages.vmLxc.appEditor[key], 'string')
}
console.log('PASS Docker delegation: live cache, lifecycle invalidation, edit protection, no extra HTTP, unique image counts, eight locales')
@@ -0,0 +1,271 @@
"""PR integration regressions. No network, guests, sidecars or notifications."""
import copy
import hashlib
import json
import sys
import threading
import time
import types
import unittest
from unittest.mock import Mock, patch
from test_update_method_choice import apps, routes
ITEM = {'api_host': 'ghcr.io', 'repository': 'example/app',
'reference': 'ghcr.io/example/app:latest', 'used_by': ['example'], 'standalone_containers': ['example'],
'remote_digest': 'sha256:' + 'a' * 64,
'installed_version': '1.0.0', 'update_available': True,
'available_version_source': 'remote_metadata_pending',
'installed_version_source': 'image_label:org.opencontainers.image.version',
'platform': {'os': 'linux', 'architecture': 'amd64', 'variant': ''}}
LABELS = {'org.opencontainers.image.version': '2.0.0'}
APP = {'id': 'a1', 'name': 'Example', 'installed_via': 'docker_exec',
'container_name': 'example', 'binary_path': '/app',
'binary_args': ['--version'], 'installed_regex': r'(\d+\.\d+\.\d+)',
'update_via': 'docker'}
class RegistryRegressions(unittest.TestCase):
def setUp(self):
apps._docker_remote_config_cache.clear()
def test_container_reference_does_not_follow_unused_aliases(self):
self.assertEqual(apps._docker_reference_identity('redis'), apps._docker_reference_identity('docker.io/library/redis:latest'))
self.assertNotEqual(apps._docker_reference_identity('redis:7-alpine'), apps._docker_reference_identity('redis:7.2.4-alpine'))
self.assertIsNone(apps._docker_reference_identity('redis@sha256:' + 'a' * 64))
self.assertIsNone(apps._docker_reference_identity('sha256:' + 'a' * 64))
def test_inventory_tracks_running_image_and_ignores_unused_alias_tag(self):
repo = 'ghcr.io/demo/app'
old_id, new_id = 'sha256:' + '1'*64, 'sha256:' + '2'*64
old_digest, new_digest = 'sha256:' + 'a'*64, 'sha256:' + 'b'*64
container = {'Name': '/example', 'Image': old_id, 'Config': {'Image': repo + ':latest', 'Labels': {}}}
image = lambda image_id, digest, version: {'Id': image_id, 'RepoDigests': [repo+'@'+digest],
'Config': {'Labels': {'org.opencontainers.image.version': version}}, 'Os': 'linux', 'Architecture': 'amd64'}
for pulled in (False, True):
def execute(vmid, argv, **kwargs):
if argv[:2] == ['docker', 'version']:
return 0, '26.1.5', ''
if argv[:2] == ['docker', 'ps']:
return 0, f'example\t{repo}:latest\tUp 1 minute', ''
if argv[:2] == ['docker', 'inspect']:
return 0, json.dumps(container), ''
if argv[:3] == ['docker', 'image', 'ls']:
return 0, '\n'.join([
f'{repo}\tlatest\t{new_digest if pulled else old_digest}\t{new_id if pulled else old_id}',
f'{repo}\t1.0.0\t{old_digest}\t{old_id}',
]), ''
if argv[:3] == ['docker', 'image', 'inspect']:
return 0, '\n'.join(json.dumps(item) for item in [image(old_id, old_digest, '1.0.0'), image(new_id, new_digest, '2.0.0')]), ''
raise AssertionError(argv)
with patch.object(apps, '_pct_exec', side_effect=execute), \
patch.object(apps, '_docker_service_catalog_meta', return_value={}), \
patch.object(apps, '_fetch_registry_manifest_digest', return_value=(new_digest, None)):
inventory = apps._docker_inventory_from_ct(101)
self.assertEqual(len(inventory['images']), 1, 'unused alias is not a workload')
actual = inventory['images'][0]
self.assertEqual(actual['reference'], repo+':latest')
self.assertEqual(actual['installed_version'], '1.0.0', 'a pull alone does not update a container')
self.assertEqual(actual['image_id'], old_id)
self.assertEqual(actual['local_digest'], old_digest)
self.assertTrue(actual['update_available'])
def test_transient_error_does_not_poison_retry(self):
with patch.object(apps, '_remote_image_config_labels', side_effect=[(None, 'timeout'), (LABELS, None)]) as read:
self.assertEqual(apps._docker_available_version_from_registry(ITEM)[1], 'remote_fetch_error')
self.assertEqual(apps._docker_available_version_from_registry(ITEM)[0], '2.0.0')
self.assertEqual(apps._docker_available_version_from_registry(ITEM)[0], '2.0.0')
self.assertEqual(read.call_count, 2)
def test_cache_key_includes_os_and_variant(self):
with patch.object(apps, '_remote_image_config_labels', side_effect=[({'Version': str(i)}, None) for i in range(3)]) as read:
for i, platform in enumerate([
{'os': 'linux', 'architecture': 'arm', 'variant': 'v7'},
{'os': 'linux', 'architecture': 'arm', 'variant': 'v6'},
{'os': 'other', 'architecture': 'arm', 'variant': 'v6'},
]):
self.assertEqual(apps._fetch_remote_image_config_labels(ITEM, ITEM['remote_digest'], platform)[0], {'Version': str(i)})
self.assertEqual(read.call_count, 3)
def test_concurrent_identical_images_only_fetch_once(self):
import concurrent.futures
def read(*_):
time.sleep(.02)
return LABELS, None
with patch.object(apps, '_remote_image_config_labels', side_effect=read) as fetch:
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(apps._docker_available_version_from_registry, [ITEM] * 4))
self.assertEqual(fetch.call_count, 1)
self.assertTrue(all(result[0] == '2.0.0' for result in results))
def test_valid_digest_with_non_object_document_is_rejected(self):
for payload in ([], None, 'text', 42):
body = json.dumps(payload).encode()
digest = 'sha256:' + hashlib.sha256(body).hexdigest()
with patch.object(apps, '_registry_request', return_value=({}, body, None, None)):
document, _, error = apps._registry_get_document('https://example.test', digest, {}, None)
self.assertIsNone(document)
self.assertIn('not an object', error)
def test_malformed_nested_configs_do_not_escape(self):
for payload in ({'config': []}, {'config': {'Labels': ['bad']}}, {'config': {'Labels': {'version': {}}}}):
body = json.dumps(payload).encode()
digest = 'sha256:' + hashlib.sha256(body).hexdigest()
manifest = {'config': {'mediaType': 'application/vnd.oci.image.config.v1+json', 'digest': digest}}
with patch.object(apps, '_registry_get_document', return_value=(manifest, None, None)), \
patch.object(apps, '_registry_request', return_value=({}, body, None, None)):
labels, error = apps._remote_image_config_labels(ITEM, ITEM['remote_digest'], ITEM['platform'])
self.assertIsNone(labels)
self.assertTrue(error)
def test_redirect_authentication_failure_is_not_success(self):
with patch.object(apps, '_registry_open', side_effect=[
({}, None, 307, 'https://cdn.example.test/blob', None),
({}, None, 401, None, None),
]) as request:
self.assertEqual(apps._registry_request('https://registry.example.test', {}, token='secret')[3], 'registry HTTP 401')
self.assertNotIn('Authorization', request.call_args.args[1])
def test_no_network_on_cached_inventory_reads(self):
with patch.object(apps, '_docker_inventory_cache', {'101': {'available': True, 'checked_at_unix': time.time(), 'images': [ITEM]}}), \
patch.object(apps, '_remote_image_config_labels', side_effect=AssertionError('network')), \
patch.object(apps, '_docker_inventory_from_ct', side_effect=AssertionError('scan')):
self.assertEqual(len(apps.get_docker_inventory(101)['images']), 1)
self.assertEqual(len(apps.get_cached_docker_inventories()['101']['images']), 1)
def test_optional_metadata_does_not_block_or_overwrite_a_new_boot(self):
started, release = threading.Event(), threading.Event()
futures = []
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
def submit(*args):
future = pool.submit(*args)
futures.append(future)
return future
def resolve(_):
started.set()
self.assertTrue(release.wait(2))
return '2.0.0', 'remote_image_label:version'
snapshot = {'available': True, 'images': [dict(ITEM)]}
new_boot = {'available': False, 'refreshing': True, 'images': []}
with patch.object(apps, '_docker_inventory_cache', {'101': snapshot}), \
patch.object(apps, '_docker_metadata_pool', types.SimpleNamespace(submit=submit)), \
patch.object(apps, '_docker_available_version_from_registry', side_effect=resolve):
try:
apps._queue_docker_metadata('101', snapshot)
self.assertTrue(started.wait(1))
self.assertIs(apps._docker_inventory_cache['101'], snapshot)
apps._docker_inventory_cache['101'] = new_boot
finally:
release.set()
for future in futures:
future.result(2)
self.assertIs(apps._docker_inventory_cache['101'], new_boot)
self.assertNotIn('available_version', snapshot['images'][0])
def test_failed_image_does_not_prevent_other_image_metadata(self):
import concurrent.futures
futures = []
snapshot = {'available': True, 'checked_at': 'original-scan', 'images': [dict(ITEM), dict(ITEM)]}
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
def submit(*args):
future = pool.submit(*args)
futures.append(future)
return future
with patch.object(apps, '_docker_inventory_cache', {'101': snapshot}), \
patch.object(apps, '_docker_metadata_pool', types.SimpleNamespace(submit=submit)), \
patch.object(apps, '_docker_available_version_from_registry', side_effect=[ValueError('bad image'), ('2.0.0', 'remote_image_label:version')]):
apps._queue_docker_metadata('101', snapshot)
for future in futures:
future.result(2)
self.assertEqual(snapshot['images'][0]['available_version_source'], 'remote_fetch_error')
self.assertEqual(snapshot['images'][1]['available_version'], '2.0.0')
self.assertTrue(snapshot['images'][0]['update_available'])
self.assertEqual(snapshot['checked_at'], 'original-scan')
class DelegationRegressions(unittest.TestCase):
def test_delegated_label_reads_actual_image_not_preserved_container_label(self):
image_id = 'sha256:' + 'a' * 64
config = {**APP, 'installed_via': 'docker_label', 'label': 'org.opencontainers.image.version'}
with patch.object(apps, '_pct_exec', side_effect=[(0, image_id, ''), (0, '2.0.0', '')]) as execute:
self.assertEqual(apps.detect_installed_version(101, config), ('2.0.0', None))
self.assertEqual(execute.call_args.args[1][:3], ['docker', 'image', 'inspect'])
self.assertEqual(execute.call_args.args[1][-1], image_id)
with patch.object(apps, '_pct_exec', return_value=(0, '1.0.0', '')) as execute:
self.assertEqual(apps.detect_installed_version(101, {**config, 'update_via': ''}), ('1.0.0', None))
self.assertEqual(execute.call_count, 1, 'existing non-delegated detector semantics remain unchanged')
def inventory(self):
return {'available': True, 'images': [dict(ITEM), {'reference': 'other:latest', 'used_by': ['other']}],
'update_units': [
{'id': 'docker-unit:aaaaaaaaaaaaaaaaaaaa', 'kind': 'standalone', 'references': [ITEM['reference']],
'standalone_containers': ['example']},
{'id': 'docker-unit:bbbbbbbbbbbbbbbbbbbb', 'kind': 'standalone', 'references': ['other:latest'],
'standalone_containers': ['other']}]}
def test_missing_inventory_clears_old_decoration(self):
records = [{**APP, 'docker_available_version': '2.0.0', 'docker_update_available': True}]
apps.annotate_delegated_apps(records, None)
self.assertIsNone(records[0]['docker_available_version'])
self.assertIsNone(records[0]['docker_update_available'])
self.assertEqual(records[0]['docker_binding_error'], 'inventory_unavailable')
def test_delegated_and_custom_or_helper_cannot_both_be_selected(self):
for extra in ({'update_command': '/opt/update.sh'}, {'update_method': 'helper', 'helper_slug': 'vaultwarden'}):
self.assertFalse(apps.validate_config({**APP, **extra})[0])
self.assertTrue(apps.validate_config({**APP, 'update_via': '', 'update_command': '/opt/update.sh'})[0])
def test_only_followed_workloads_are_offered(self):
inventory = self.inventory()
scoped = apps.docker_inventory_for_apps([APP], inventory)
self.assertEqual([image['reference'] for image in scoped['images']], [ITEM['reference']])
self.assertEqual([unit['id'] for unit in scoped['update_units']], ['docker-unit:aaaaaaaaaaaaaaaaaaaa'])
self.assertEqual(len(inventory['images']), 2)
def test_notifications_without_engine_registration_honor_opt_out_and_dedup(self):
manager = Mock()
inventory = self.inventory()
records = [dict(APP), {**APP, 'id': 'a2'}]
with patch.object(apps, 'get_cached_docker_inventories', return_value={'101': inventory}), \
patch.object(apps, '_read_sidecar', return_value={'apps': records}), \
patch.dict(sys.modules, {'notification_manager': types.SimpleNamespace(notification_manager=manager)}):
self.assertEqual(apps.emit_all_pending_docker_stacks(), 1)
first = manager.emit_event.call_args.kwargs
self.assertEqual(first['data']['count'], 1)
inventory['images'][0]['available_version'] = '2.0.0'
self.assertEqual(apps.emit_all_pending_docker_stacks(), 1)
self.assertEqual(first['entity_id'], manager.emit_event.call_args.kwargs['entity_id'])
for record in records:
record['notifications_enabled'] = False
self.assertEqual(apps.emit_all_pending_docker_stacks(), 0)
def test_bulk_allows_followed_image_but_not_engine_or_other_image(self):
api = routes()
with patch.object(apps, 'load_sidecar', return_value={'apps': [APP]}), \
patch.object(apps, '_read_sidecar', return_value={'apps': [APP]}), \
patch.object(apps, 'get_docker_inventory', return_value=self.inventory()):
plan = api['_resolve_bulk_update_plan'](101, ['os', 'docker-unit:aaaaaaaaaaaaaaaaaaaa'])
self.assertTrue(plan['ok'], plan)
self.assertFalse(plan['unavailable'], plan)
self.assertIn('example', plan['docker_standalone_targets'])
plan = api['_resolve_bulk_update_plan'](101, ['os', 'docker-unit:bbbbbbbbbbbbbbbbbbbb', 'docker-engine'])
self.assertEqual(len(plan['unavailable']), 2, plan)
def test_schedule_runs_only_selected_followed_container(self):
api = routes()
with patch.object(apps, '_read_sidecar', return_value={'apps': [APP]}), \
patch.object(apps, 'load_sidecar', return_value={'apps': [APP]}), \
patch.object(apps, 'get_docker_inventory', return_value=self.inventory()):
result = api['_run_scheduled_update'](101, {'targets': ['docker-container:example', 'docker-container:other', 'docker-engine']})
self.assertEqual(result['status'], 'partial', result)
self.assertEqual(result['executed_targets'], ['docker-container:example'])
env = api['subprocess'].run.call_args.kwargs['env']
self.assertEqual(env['DOCKER_STANDALONE_TARGETS'], 'example')
self.assertEqual(env['UPDATE_DOCKER_ENGINE'], '0')
self.assertEqual(env['RUN_HELPER'], '0')
if __name__ == '__main__':
unittest.main()