mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user