mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-21 14:16:48 +00:00
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:
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 l’inventaire 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 s’exé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 l’onglet Mises à jour.",
|
||||
"closePanel": "Fermer le panneau",
|
||||
"cancelButton": "Annuler",
|
||||
"saveButton": "Sauvegarder",
|
||||
|
||||
@@ -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 l’inventario 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 l’immagine nella scheda Aggiornamenti.",
|
||||
"closePanel": "Chiudi pannello",
|
||||
"cancelButton": "Cancellare",
|
||||
"saveButton": "Salva",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ť",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user