mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Reuse OCI images and apply resource edits without rebuilding
This commit is contained in:
@@ -28,6 +28,10 @@ jobs:
|
||||
python-version: '3.13'
|
||||
- name: Install OCI test dependencies
|
||||
run: python3 -m pip install -r oci/requirements.txt
|
||||
- name: Run OCI image reuse and resource edit regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s oci/tests -p test_modify_image_reuse.py -v
|
||||
- name: Run offline registry account tests
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
|
||||
@@ -443,6 +443,7 @@
|
||||
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Die geprüfte Paketaktualisierung jetzt anwenden? Dienste können neu starten und zur Entfernung aufgeführte Pakete werden entfernt.",
|
||||
"Apply these changes?": "Diese Änderungen anwenden?",
|
||||
"Applying AMD-specific fixes...": "Anwenden von AMD-spezifischen Korrekturen...",
|
||||
"Applying CPU and RAM changes without rebuilding the container...": "CPU- und RAM-Änderungen werden ohne Neuerstellung des Containers angewendet...",
|
||||
"Applying Changes": "Anwenden von Änderungen",
|
||||
"Applying Controller/NVMe passthrough to VM": "Anwenden von Controller/NVMe-Passthrough auf die VM",
|
||||
"Applying balanced memory optimization settings...": "Ausgewogene Einstellungen zur Speicheroptimierung werden angewendet...",
|
||||
@@ -6636,6 +6637,7 @@
|
||||
"The repair must preserve the image dependencies:": "Die Reparatur muss die Bildabhängigkeiten bewahren:",
|
||||
"The requested VMID block is already in use": "Der angeforderte VMID-Block wird bereits verwendet",
|
||||
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "Das angeforderte Machine Learning-GPU-Profil funktioniert nicht; es wird nicht durch CPU ersetzt",
|
||||
"The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Die Ressourcenänderung konnte nicht vollständig rückgängig gemacht werden. Öffne die Verwaltung erneut, um die aktuellen Proxmox-Einstellungen zu prüfen und zu übernehmen.",
|
||||
"The restored service did not pass its health check": "Der wiederhergestellte Service bestand seinen Gesundheitscheck nicht",
|
||||
"The restored service stopped; the recovery is not confirmed": "Der wiederhergestellte Dienst wurde gestoppt; die Wiederherstellung wird nicht bestätigt",
|
||||
"The reviewed Tandoor stack does not require a privileged LXC.": "Der überprüfte Tandoor-Stack requi ist kein privilegierter LXC.",
|
||||
|
||||
@@ -440,6 +440,7 @@
|
||||
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "¿Aplicar ahora la actualización de paquetes revisada? Los servicios podrían reiniciarse y se eliminarán los paquetes indicados para su eliminación.",
|
||||
"Apply these changes?": "¿Aplicar estos cambios?",
|
||||
"Applying AMD-specific fixes...": "Aplicando correcciones específicas de AMD...",
|
||||
"Applying CPU and RAM changes without rebuilding the container...": "Aplicando los cambios de CPU y RAM sin recrear el contenedor...",
|
||||
"Applying Changes": "Aplicando cambios",
|
||||
"Applying Controller/NVMe passthrough to VM": "Aplicando el passthrough de controlador/NVMe a la VM",
|
||||
"Applying balanced memory optimization settings...": "Aplicando configuraciones equilibradas de optimización de memoria...",
|
||||
@@ -6612,6 +6613,7 @@
|
||||
"The repair must preserve the image dependencies:": "La reparación debe preservar las dependencias de la imagen:",
|
||||
"The requested VMID block is already in use": "El bloque VMID solicitado ya está en uso",
|
||||
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "El perfil de GPU solicitado para Machine learning no funciona; no se sustituye por CPU",
|
||||
"The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "El cambio de recursos no se ha podido revertir por completo. Vuelve al menú de gestión para revisar e incorporar la configuración actual de Proxmox.",
|
||||
"The restored service did not pass its health check": "El servicio restaurado no superó su comprobación de estado",
|
||||
"The restored service stopped; the recovery is not confirmed": "El servicio restaurado se detuvo; la recuperación no se confirma",
|
||||
"The reviewed Tandoor stack does not require a privileged LXC.": "La pila de Tandoor revisada no necesita un LXC privilegiado.",
|
||||
|
||||
@@ -443,6 +443,7 @@
|
||||
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Appliquer maintenant la mise à niveau des paquets examinée ? Des services peuvent redémarrer et les paquets indiqués pour suppression seront supprimés.",
|
||||
"Apply these changes?": "Appliquer ces modifications ?",
|
||||
"Applying AMD-specific fixes...": "Application de correctifs spécifiques à AMD...",
|
||||
"Applying CPU and RAM changes without rebuilding the container...": "Application des modifications de CPU et de RAM sans recréer le conteneur...",
|
||||
"Applying Changes": "Application des modifications",
|
||||
"Applying Controller/NVMe passthrough to VM": "Application du relais Controller/NVMe à la VM",
|
||||
"Applying balanced memory optimization settings...": "Application des paramètres d'optimisation de la mémoire équilibrée...",
|
||||
@@ -6635,6 +6636,7 @@
|
||||
"The repair must preserve the image dependencies:": "La réparation doit préserver les dépendances de l'image:",
|
||||
"The requested VMID block is already in use": "Le bloc VMID demandé est déjà utilisé",
|
||||
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "Le profil GPU d'apprentissage automatique demandé ne fonctionne pas; il n'est pas remplacé par CPU",
|
||||
"The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Impossible d’annuler entièrement la modification des ressources. Rouvrez le menu de gestion pour vérifier et importer les réglages actuels de Proxmox.",
|
||||
"The restored service did not pass its health check": "Le service restauré n'a pas réussi son bilan de santé",
|
||||
"The restored service stopped; the recovery is not confirmed": "Le service restauré s'est arrêté; la récupération n'est pas confirmée",
|
||||
"The reviewed Tandoor stack does not require a privileged LXC.": "La pile Tandoor revue ne require un LXC privilégié.",
|
||||
|
||||
@@ -443,6 +443,7 @@
|
||||
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Applicare ora l'aggiornamento dei pacchetti esaminato? I servizi potrebbero riavviarsi e i pacchetti indicati per la rimozione verranno rimossi.",
|
||||
"Apply these changes?": "Applicare queste modifiche?",
|
||||
"Applying AMD-specific fixes...": "Applicazione delle correzioni specifiche AMD...",
|
||||
"Applying CPU and RAM changes without rebuilding the container...": "Applicazione delle modifiche a CPU e RAM senza ricreare il container...",
|
||||
"Applying Changes": "Applicazione delle modifiche",
|
||||
"Applying Controller/NVMe passthrough to VM": "Applicazione del passthrough Controller/NVMe alla VM",
|
||||
"Applying balanced memory optimization settings...": "Applicazione delle impostazioni bilanciate di ottimizzazione della memoria...",
|
||||
@@ -6636,6 +6637,7 @@
|
||||
"The repair must preserve the image dependencies:": "La riparazione deve conservare le dipendenze dell'immagine:",
|
||||
"The requested VMID block is already in use": "Il blocco di VMID richiesto è già in uso",
|
||||
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "Il profilo GPU richiesto per il machine learning non funziona; non viene sostituito dalla CPU",
|
||||
"The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Non è stato possibile annullare completamente la modifica delle risorse. Riapri il menu di gestione per verificare e importare le impostazioni attuali di Proxmox.",
|
||||
"The restored service did not pass its health check": "Il servizio ripristinato non ha superato il controllo dello stato",
|
||||
"The restored service stopped; the recovery is not confirmed": "Il servizio ripristinato si è arrestato; il recupero non è confermato",
|
||||
"The reviewed Tandoor stack does not require a privileged LXC.": "Lo stack Tandoor esaminato non richiede un container LXC privilegiato.",
|
||||
|
||||
@@ -443,6 +443,7 @@
|
||||
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Aplicar agora a atualização de pacotes revista? Os serviços podem reiniciar e os pacotes indicados para remoção serão removidos.",
|
||||
"Apply these changes?": "Aplicar essas alterações?",
|
||||
"Applying AMD-specific fixes...": "Aplicando correções específicas da AMD...",
|
||||
"Applying CPU and RAM changes without rebuilding the container...": "A aplicar as alterações de CPU e RAM sem recriar o contentor...",
|
||||
"Applying Changes": "Aplicando alterações",
|
||||
"Applying Controller/NVMe passthrough to VM": "Aplicando passagem de controlador/NVMe à VM",
|
||||
"Applying balanced memory optimization settings...": "Aplicando configurações de otimização de memória balanceadas...",
|
||||
@@ -6636,6 +6637,7 @@
|
||||
"The repair must preserve the image dependencies:": "O reparo deve preservar as dependências da imagem:",
|
||||
"The requested VMID block is already in use": "O bloco VMID solicitado já está em uso",
|
||||
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "O perfil de aprendizado de máquina solicitado GPU não está funcionando; não é substituído por CPU",
|
||||
"The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Não foi possível reverter totalmente a alteração dos recursos. Volte a abrir o menu de gestão para rever e importar as definições atuais do Proxmox.",
|
||||
"The restored service did not pass its health check": "O serviço restaurado não passou no seu exame de saúde",
|
||||
"The restored service stopped; the recovery is not confirmed": "O serviço restaurado parou; a recuperação não é confirmada",
|
||||
"The reviewed Tandoor stack does not require a privileged LXC.": "A pilha Tandoor revisada não require um LXC privilegiado.",
|
||||
|
||||
@@ -443,6 +443,7 @@
|
||||
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Použiť teraz skontrolovanú aktualizáciu balíkov? Služby sa môžu reštartovať a balíky označené na odstránenie budú odstránené.",
|
||||
"Apply these changes?": "Použiť tieto zmeny?",
|
||||
"Applying AMD-specific fixes...": "Používam opravy špecifické pre AMD...",
|
||||
"Applying CPU and RAM changes without rebuilding the container...": "Používajú sa zmeny CPU a RAM bez opätovného vytvorenia kontajnera...",
|
||||
"Applying Changes": "Používam zmeny",
|
||||
"Applying Controller/NVMe passthrough to VM": "Používam priame priradenie Controller/NVMe do VM",
|
||||
"Applying balanced memory optimization settings...": "Používam vyvážené nastavenia optimalizácie pamäte...",
|
||||
@@ -6637,6 +6638,7 @@
|
||||
"The repair must preserve the image dependencies:": "Oprava musí zachovať závislosť obrazu:",
|
||||
"The requested VMID block is already in use": "Požadovaný blok VMID sa už používa",
|
||||
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "Požadovaný profil strojového učenia sa GPU nefunguje; nie je nahradený CPU",
|
||||
"The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Zmenu prostriedkov sa nepodarilo úplne vrátiť späť. Znova otvorte správu aplikácií, skontrolujte aktuálne nastavenia systému Proxmox a prevezmite ich.",
|
||||
"The restored service did not pass its health check": "Obnovená služba neprešla kontrolou stavu",
|
||||
"The restored service stopped; the recovery is not confirmed": "Obnovená služba sa zastavila; obnova sa nepotvrdila",
|
||||
"The reviewed Tandoor stack does not require a privileged LXC.": "Preskúmaný Tandoor stack require privilegovaný LXC.",
|
||||
|
||||
@@ -443,6 +443,7 @@
|
||||
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Tillämpa den granskade paketuppgraderingen nu? Tjänster kan startas om och paket som anges för borttagning kommer att tas bort.",
|
||||
"Apply these changes?": "Tillämpa dessa ändringar?",
|
||||
"Applying AMD-specific fixes...": "Tillämpar AMD-specifika korrigeringar...",
|
||||
"Applying CPU and RAM changes without rebuilding the container...": "Tillämpar ändringarna av CPU och RAM utan att återskapa containern...",
|
||||
"Applying Changes": "Tillämpa ändringar",
|
||||
"Applying Controller/NVMe passthrough to VM": "Tillämpar styrenhets-/NVMe-passthrough på VM",
|
||||
"Applying balanced memory optimization settings...": "Tillämpar inställningar för balanserad minnesoptimering...",
|
||||
@@ -6636,6 +6637,7 @@
|
||||
"The repair must preserve the image dependencies:": "Reparationen måste bevara bildberoenden:",
|
||||
"The requested VMID block is already in use": "Det begärda VMID-blocket används redan",
|
||||
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "Den begärda maskininlärningen GPU-profilen fungerar inte; den ersätts inte av CPU.",
|
||||
"The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Det gick inte att helt återställa resursändringen. Öppna hanteringsmenyn igen för att granska och importera de aktuella Proxmox-inställningarna.",
|
||||
"The restored service did not pass its health check": "Den återställda tjänsten passerade inte hälsokontrollen",
|
||||
"The restored service stopped; the recovery is not confirmed": "Den återställda tjänsten stannade; återhämtningen bekräftas inte",
|
||||
"The reviewed Tandoor stack does not require a privileged LXC.": "Den granskade Tandoor stack inte require en privilegierad LXC.",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Apply CPU/RAM-only proposals to the existing guest, without an image archive."""
|
||||
import copy
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
import oci_instances as instances
|
||||
import oci_instance_transaction as transaction
|
||||
from oci_installation_state import parse_config, sha
|
||||
from oci_ui import translate, msg_info, msg_ok, msg_warn
|
||||
|
||||
CONFIG_DIR = Path('/etc/pve/lxc')
|
||||
|
||||
|
||||
def changes(record, candidate, config):
|
||||
"""Return a narrow native edit, or None when the normal installer is needed."""
|
||||
previous = transaction.with_default_healthcheck(copy.deepcopy(record))
|
||||
candidate = transaction.with_default_healthcheck(copy.deepcopy(candidate))
|
||||
old, new = previous['deployment'], candidate['deployment']
|
||||
if previous['template'] != candidate['template'] or old.get('host_monitor'):
|
||||
return None
|
||||
if {k: v for k, v in old.items() if k != 'resources'} != {
|
||||
k: v for k, v in new.items() if k != 'resources'}:
|
||||
return None
|
||||
original, desired = old.get('resources', {}), new.get('resources', {})
|
||||
changed = {key for key in original.keys() | desired.keys() if original.get(key) != desired.get(key)}
|
||||
if not changed or changed - {'cores', 'memory_mb'}:
|
||||
return None
|
||||
cfg = parse_config(config)
|
||||
cpu = 'cpulimit' if 'cpulimit' in cfg and 'cores' not in cfg else 'cores'
|
||||
parameters = {}
|
||||
for key in sorted(changed):
|
||||
value = desired.get(key)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < (128 if key == 'memory_mb' else 1):
|
||||
return None
|
||||
native = 'memory' if key == 'memory_mb' else cpu
|
||||
if native not in cfg:
|
||||
return None
|
||||
parameters[native] = str(value)
|
||||
return parameters
|
||||
|
||||
|
||||
def guarded_set(vmid, installation_id, expected, parameters):
|
||||
# pct uses this SHA1 under its own configuration lock. Sample the native
|
||||
# file before checking pct's view, so a concurrent edit cannot be accepted
|
||||
# accidentally by taking a fresh digest after validation.
|
||||
native = (CONFIG_DIR / f'{vmid}.conf').read_bytes()
|
||||
current = instances.command('pct', 'config', str(vmid))
|
||||
if current != expected or instances.identity(current) != installation_id:
|
||||
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
|
||||
command = ['pct', 'set', str(vmid), '--digest', hashlib.sha1(native).hexdigest()]
|
||||
for key, value in sorted(parameters.items()):
|
||||
command += ['--' + key, value]
|
||||
transaction.run(*command)
|
||||
|
||||
|
||||
def expected_config(before, after, parameters):
|
||||
def unrelated(config):
|
||||
return b''.join(line for line in config.splitlines(keepends=True)
|
||||
if line.split(b':', 1)[0].decode() not in parameters)
|
||||
new = parse_config(after)
|
||||
return unrelated(before) == unrelated(after) and all(new.get(key) == value for key, value in parameters.items())
|
||||
|
||||
|
||||
def apply(root, record, candidate, config, parameters):
|
||||
"""The caller holds the instance lock and has checked image identity/preflight."""
|
||||
if changes(record, candidate, config) != parameters:
|
||||
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
|
||||
vmid = record['vmid']
|
||||
directory = instances.location(root, vmid).parent / 'modifications' / uuid.uuid4().hex
|
||||
instances.write(directory / 'before.json', {'record': record, 'config': config.decode()})
|
||||
transaction.open_log(directory)
|
||||
msg_info(translate('Applying CPU and RAM changes without rebuilding the container...'))
|
||||
applied = False
|
||||
after = None
|
||||
try:
|
||||
guarded_set(vmid, record['installation_id'], config, parameters)
|
||||
applied = True
|
||||
after = instances.command('pct', 'config', str(vmid))
|
||||
if not expected_config(config, after, parameters):
|
||||
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
|
||||
observed = record['observed']
|
||||
fresh = instances.observe(vmid, record['installation_id'], observed['archive_path'],
|
||||
observed['resolved_registry_digest'], observed['image'])
|
||||
if fresh['config_sha256'] != sha(after):
|
||||
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
|
||||
updated = copy.deepcopy(record)
|
||||
updated.update(deployment=copy.deepcopy(candidate['deployment']), observed=fresh)
|
||||
instances.write(instances.location(root, vmid), updated)
|
||||
except Exception:
|
||||
if applied:
|
||||
try:
|
||||
current = instances.command('pct', 'config', str(vmid))
|
||||
if not expected_config(config, current, parameters):
|
||||
raise ValueError('Concurrent configuration change; resource rollback refused')
|
||||
previous = parse_config(config)
|
||||
guarded_set(vmid, record['installation_id'], current,
|
||||
{key: previous[key] for key in parameters})
|
||||
instances.write(instances.location(root, vmid), record)
|
||||
except Exception as error:
|
||||
transaction.log(f'resource rollback: {type(error).__name__}')
|
||||
msg_warn(translate('The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.'))
|
||||
raise
|
||||
# Updating the carried record is best effort here, as it is after other
|
||||
# management operations. The normal menu also refreshes it on return.
|
||||
try:
|
||||
import oci_carried_record
|
||||
oci_carried_record.carry(root, vmid, mount_stopped=False)
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
transaction.log('carried record will be refreshed on the next management entry')
|
||||
msg_ok(translate('Changes applied. Data kept.'))
|
||||
return directory
|
||||
@@ -8,6 +8,7 @@ import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
import oci_instances as instances
|
||||
@@ -56,7 +57,7 @@ def run_quiet(args, error, capture=False):
|
||||
process.wait()
|
||||
|
||||
|
||||
def resolve_archive(desired, config, current=None, check=None, required_digest=None):
|
||||
def resolve_archive(desired, config, current=None, check=None, required_digest=None, local_archives=()):
|
||||
# Shared by individual and coordinated operations; no guest mutation here.
|
||||
# When the registry still serves the current digest nothing is downloaded.
|
||||
reference = desired['template']['container_contract']['image']['reference']
|
||||
@@ -83,6 +84,27 @@ def resolve_archive(desired, config, current=None, check=None, required_digest=N
|
||||
return None, digest
|
||||
if check:
|
||||
check()
|
||||
verifier = Path(__file__).with_name('verify_oci_archive.py')
|
||||
|
||||
def intact(path):
|
||||
try:
|
||||
run_quiet([sys.executable, str(verifier), str(path)],
|
||||
translate('The image did not pass the integrity check'))
|
||||
return same_image(candidate, image_from_archive(str(path)))
|
||||
except (RuntimeError, OSError, ValueError, KeyError, tarfile.TarError):
|
||||
return False
|
||||
|
||||
# Initial installations and updates use different archive names. The
|
||||
# instance record knows the actual archive; validate it before looking
|
||||
# only for the deterministic update-cache name. Never delete a rejected
|
||||
# recorded path: another instance may still refer to it.
|
||||
for saved in local_archives:
|
||||
path = Path(saved) if saved else None
|
||||
if path and not path.is_symlink() and path.is_file():
|
||||
msg_info(translate('Verifying the image integrity...'))
|
||||
if intact(path):
|
||||
msg_ok(translate('Using the verified image from the cache'))
|
||||
return path, digest
|
||||
storage = desired['deployment']['template_storage']
|
||||
if not re.fullmatch(r'[A-Za-z0-9_-]+', storage):
|
||||
raise ValueError(translate('Invalid template storage'))
|
||||
@@ -91,16 +113,6 @@ def resolve_archive(desired, config, current=None, check=None, required_digest=N
|
||||
archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
if archive.is_symlink():
|
||||
raise ValueError(translate('Unsafe OCI archive path'))
|
||||
verifier = Path(__file__).with_name('verify_oci_archive.py')
|
||||
|
||||
def intact(path):
|
||||
try:
|
||||
run_quiet([sys.executable, str(verifier), str(path)],
|
||||
translate('The image did not pass the integrity check'))
|
||||
except RuntimeError:
|
||||
return False
|
||||
return same_image(candidate, image_from_archive(str(path)))
|
||||
|
||||
if archive.exists():
|
||||
msg_info(translate('Verifying the image integrity...'))
|
||||
if intact(archive):
|
||||
@@ -181,10 +193,23 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non
|
||||
changes = transaction.external_changes(record, config)
|
||||
transaction.preflight(record, desired, config)
|
||||
msg_ok(translate('Container checked'))
|
||||
current = record['observed']['image'] if operation == 'update' else None
|
||||
archive, digest = resolve_archive(desired, config, current, lambda: work_backup.locate(
|
||||
instances.location(instances.ROOT, vmid).parent / 'transactions' / 'check', [vmid], record, vmid))
|
||||
resource_changes = None
|
||||
if operation == 'recreate' and not keep_backup:
|
||||
import oci_resource_modify
|
||||
resource_changes = oci_resource_modify.changes(record, desired, config)
|
||||
current = record['observed']['image'] if operation == 'update' or resource_changes else None
|
||||
check = lambda: work_backup.locate(
|
||||
instances.location(instances.ROOT, vmid).parent / 'transactions' / 'check', [vmid], record, vmid)
|
||||
if operation == 'recreate':
|
||||
archive, digest = resolve_archive(desired, config, current, check,
|
||||
local_archives=[record['observed'].get('archive_path')])
|
||||
else:
|
||||
archive, digest = resolve_archive(desired, config, current, check)
|
||||
if archive is None:
|
||||
if resource_changes:
|
||||
import oci_operation_notice
|
||||
with oci_operation_notice.operation([vmid], 'modify', application_name(record, vmid)):
|
||||
return oci_resource_modify.apply(instances.ROOT, record, desired, config, resource_changes)
|
||||
msg_ok(translate('The image is already up to date; nothing was changed.'))
|
||||
return
|
||||
file_storage = None
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Unchanged OCI images permit native resource edits or verified local reuse."""
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT/'remote'))
|
||||
import oci_carried_record
|
||||
import oci_instances as instances
|
||||
import oci_resource_modify as resources
|
||||
import oci_update_current as update
|
||||
|
||||
DIGEST = 'sha256:'+'a'*64
|
||||
NEW = 'sha256:'+'b'*64
|
||||
IDENTITY = '11111111-1111-4111-8111-111111111111'
|
||||
CONFIG = (f'arch: amd64\ncores: 2\ndescription: proxmenux-instance={IDENTITY}\n'
|
||||
'memory: 512\nrootfs: local-zfs:subvol-115-disk-0,size=8G\n'
|
||||
'lxc.environment.runtime: A=one\nlxc.environment.runtime: B=two\n').encode()
|
||||
|
||||
|
||||
def record():
|
||||
return {'schema_version': 1, 'vmid': 115, 'installation_id': IDENTITY, 'status': 'installed',
|
||||
'template': {'id': 'example-app', 'catalog_ui': {'title': 'Example'},
|
||||
'container_contract': {'image': {'reference': 'example/app:latest'}},
|
||||
'proxmox': {'installer_profile': {'startup_healthcheck': {'type': 'running'}}}},
|
||||
'deployment': {'vmid': 115, 'hostname': 'example', 'template_storage': 'local',
|
||||
'resources': {'cores': 2, 'memory_mb': 512, 'swap_mb': 512},
|
||||
'network': {'bridge': 'vmbr0', 'ipv4': 'dhcp'}, 'mounts': [], 'devices': [],
|
||||
'environment': [{'name': 'PASSWORD', 'value': 'private-test-value', 'sensitive': True}]},
|
||||
'observed': {'config': CONFIG.decode(), 'config_sha256': resources.sha(CONFIG),
|
||||
'image': {'manifest_digest': DIGEST, 'architecture': 'amd64', 'os': 'linux'},
|
||||
'resolved_registry_digest': DIGEST, 'archive_path': '/missing/deleted-image.tar'}}
|
||||
|
||||
|
||||
class ResourceModifyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)/'instances'
|
||||
self.configs = Path(self.temp.name)/'configs'
|
||||
self.configs.mkdir()
|
||||
self.file = self.configs/'115.conf'
|
||||
self.file.write_bytes(CONFIG)
|
||||
self.saved = record()
|
||||
self.candidate = copy.deepcopy(self.saved)
|
||||
self.candidate['deployment']['resources'].update(cores=3, memory_mb=768)
|
||||
self.params = {'cores': '3', 'memory': '768'}
|
||||
instances.write(instances.location(self.root, 115), self.saved)
|
||||
self.calls = []
|
||||
self.native_failure = None
|
||||
def run(*args):
|
||||
self.calls.append(args)
|
||||
self.assertEqual(args[:3], ('pct', 'set', '115'))
|
||||
expected = args[args.index('--digest')+1]
|
||||
self.assertEqual(expected, hashlib.sha1(self.file.read_bytes()).hexdigest())
|
||||
if self.native_failure:
|
||||
raise self.native_failure
|
||||
text = self.file.read_text()
|
||||
for index in range(5, len(args), 2):
|
||||
text = re.sub(r'^'+args[index][2:]+r': .*$', args[index][2:]+': '+args[index+1], text, flags=re.M)
|
||||
self.file.write_text(text)
|
||||
return b''
|
||||
def observe(*args):
|
||||
result = copy.deepcopy(self.saved['observed'])
|
||||
result.update(config=self.file.read_text(), config_sha256=resources.sha(self.file.read_bytes()))
|
||||
return result
|
||||
patches = [patch.object(resources, 'CONFIG_DIR', self.configs),
|
||||
patch.object(instances, 'command', side_effect=lambda *args: self.file.read_bytes()),
|
||||
patch.object(resources.transaction, 'run', side_effect=run),
|
||||
patch.object(resources.transaction, 'open_log'), patch.object(resources.transaction, 'log'),
|
||||
patch.object(instances, 'observe', side_effect=observe),
|
||||
patch.object(oci_carried_record, 'carry', return_value='carried')]
|
||||
for p in patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def apply(self):
|
||||
return resources.apply(self.root, self.saved, self.candidate, CONFIG, self.params)
|
||||
|
||||
def test_resource_only_change_needs_no_archive_and_preserves_every_other_field(self):
|
||||
directory = self.apply()
|
||||
after = instances.read(self.root, 115)
|
||||
self.assertEqual(after['deployment']['resources'], {'cores': 3, 'memory_mb': 768, 'swap_mb': 512})
|
||||
for key in ('network', 'mounts', 'devices', 'environment'):
|
||||
self.assertEqual(after['deployment'][key], self.saved['deployment'][key])
|
||||
self.assertEqual(after['observed']['image'], self.saved['observed']['image'])
|
||||
self.assertEqual(after['observed']['archive_path'], '/missing/deleted-image.tar')
|
||||
self.assertEqual(after['observed']['config_sha256'], resources.sha(self.file.read_bytes()))
|
||||
self.assertNotIn('pending_transaction', after)
|
||||
self.assertEqual(len(self.calls), 1)
|
||||
self.assertEqual(stat.S_IMODE((directory/'before.json').stat().st_mode), 0o600)
|
||||
self.assertEqual(json.loads((directory/'before.json').read_text())['record'], self.saved)
|
||||
self.assertNotIn('private-test-value', str(self.calls))
|
||||
|
||||
def test_network_environment_template_and_extra_device_edits_use_the_normal_installer(self):
|
||||
for mutate in (
|
||||
lambda c: c['deployment']['network'].update(ipv4='192.0.2.2/24'),
|
||||
lambda c: c['deployment']['environment'][0].update(value='changed'),
|
||||
lambda c: c['template']['container_contract']['image'].update(reference='example/app:other'),
|
||||
lambda c: c['deployment']['devices'].append({'host_path': '/dev/ttyACM0'}),
|
||||
lambda c: c['deployment']['resources'].update(swap_mb=1024),
|
||||
):
|
||||
candidate = copy.deepcopy(self.candidate)
|
||||
mutate(candidate)
|
||||
self.assertIsNone(resources.changes(self.saved, candidate, CONFIG))
|
||||
|
||||
def test_invalid_values_and_unchanged_proposal_do_not_use_the_native_path(self):
|
||||
for value in (True, 0, -1, 64, 128.5, '768'):
|
||||
candidate = copy.deepcopy(self.saved)
|
||||
candidate['deployment']['resources']['memory_mb'] = value
|
||||
self.assertIsNone(resources.changes(self.saved, candidate, CONFIG))
|
||||
self.assertIsNone(resources.changes(self.saved, copy.deepcopy(self.saved), CONFIG))
|
||||
|
||||
def test_cpu_limit_profile_stays_a_cpu_limit(self):
|
||||
config = CONFIG.replace(b'cores: 2', b'cpulimit: 2')
|
||||
self.assertEqual(resources.changes(self.saved, self.candidate, config), {'cpulimit': '3', 'memory': '768'})
|
||||
|
||||
def test_native_failure_leaves_saved_record_and_config_unchanged(self):
|
||||
self.native_failure = RuntimeError('native API rejected edit')
|
||||
with self.assertRaisesRegex(RuntimeError, 'rejected'):
|
||||
self.apply()
|
||||
self.assertEqual(self.file.read_bytes(), CONFIG)
|
||||
self.assertEqual(instances.read(self.root, 115), self.saved)
|
||||
|
||||
def test_observation_failure_rolls_back_resource_values(self):
|
||||
with patch.object(instances, 'observe', side_effect=RuntimeError('observation failed')):
|
||||
with self.assertRaisesRegex(RuntimeError, 'observation failed'):
|
||||
self.apply()
|
||||
self.assertEqual(self.file.read_bytes(), CONFIG)
|
||||
self.assertEqual(instances.read(self.root, 115), self.saved)
|
||||
self.assertEqual(len(self.calls), 2)
|
||||
|
||||
def test_record_write_failure_rolls_back_resources(self):
|
||||
real_write = instances.write
|
||||
failed = False
|
||||
def write(path, value):
|
||||
nonlocal failed
|
||||
if Path(path) == instances.location(self.root, 115) and not failed:
|
||||
failed = True
|
||||
raise OSError('record could not be saved')
|
||||
real_write(path, value)
|
||||
with patch.object(instances, 'write', side_effect=write):
|
||||
with self.assertRaisesRegex(OSError, 'could not be saved'):
|
||||
self.apply()
|
||||
self.assertEqual(self.file.read_bytes(), CONFIG)
|
||||
self.assertEqual(instances.read(self.root, 115), self.saved)
|
||||
|
||||
def test_configuration_drift_is_rejected_before_native_set(self):
|
||||
self.file.write_bytes(CONFIG.replace(b'memory: 512', b'memory: 1024'))
|
||||
with self.assertRaisesRegex(ValueError, 'changed'):
|
||||
self.apply()
|
||||
self.assertEqual(self.calls, [])
|
||||
|
||||
def test_repeated_environment_entries_are_all_preserved_by_the_guard(self):
|
||||
changed = CONFIG.replace(b'cores: 2', b'cores: 3').replace(b'memory: 512', b'memory: 768')
|
||||
self.assertTrue(resources.expected_config(CONFIG, changed, self.params))
|
||||
self.assertFalse(resources.expected_config(CONFIG, changed.replace(b'A=one', b'A=foreign'), self.params))
|
||||
|
||||
def test_a_concurrent_unrelated_change_is_not_overwritten_during_rollback(self):
|
||||
def observe(*args):
|
||||
self.file.write_bytes(self.file.read_bytes().replace(b'A=one', b'A=foreign'))
|
||||
raise RuntimeError('concurrent edit')
|
||||
with patch.object(instances, 'observe', side_effect=observe), patch.object(resources, 'msg_warn') as warn:
|
||||
with self.assertRaisesRegex(RuntimeError, 'concurrent edit'):
|
||||
self.apply()
|
||||
self.assertIn(b'A=foreign', self.file.read_bytes())
|
||||
self.assertEqual(len(self.calls), 1)
|
||||
warn.assert_called_once()
|
||||
self.assertEqual(instances.read(self.root, 115), self.saved)
|
||||
|
||||
def test_a_carried_record_error_does_not_undo_the_successful_edit(self):
|
||||
with patch.object(oci_carried_record, 'carry', side_effect=RuntimeError('copy unavailable')):
|
||||
self.apply()
|
||||
self.assertEqual(instances.read(self.root, 115)['deployment']['resources']['memory_mb'], 768)
|
||||
self.assertEqual(len(self.calls), 1)
|
||||
|
||||
|
||||
class CachedArchiveTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.path = Path(self.temp.name)/'original-installation.tar'
|
||||
self.path.write_bytes(b'fixture verified by stub')
|
||||
self.desired = record()
|
||||
self.image = {'manifest_digest': DIGEST}
|
||||
self.candidate = dict(self.image, registry_digest=DIGEST)
|
||||
|
||||
def resolve(self, paths, image=None):
|
||||
def run(args, error, capture=False):
|
||||
self.assertNotEqual(args[0], 'skopeo', 'No layers should be downloaded')
|
||||
return json.dumps(self.candidate) if '-c' in args else b''
|
||||
with patch.object(update, 'run_quiet', side_effect=run), \
|
||||
patch.object(update, 'image_from_archive', return_value=image or self.image), \
|
||||
patch.object(instances, 'command') as command:
|
||||
result = update.resolve_archive(self.desired, b'arch: amd64\n', local_archives=paths)
|
||||
command.assert_not_called()
|
||||
return result
|
||||
|
||||
def test_original_installation_archive_is_reused_without_update_cache_lookup(self):
|
||||
self.assertEqual(self.resolve([self.path]), (self.path, DIGEST))
|
||||
|
||||
def test_invalid_original_cache_is_kept_and_a_second_matching_archive_is_reused(self):
|
||||
other = self.path.with_name('other.tar')
|
||||
other.write_bytes(b'second fixture')
|
||||
def image(path):
|
||||
return {'manifest_digest': NEW if str(path) == str(self.path) else DIGEST}
|
||||
with patch.object(update, 'run_quiet', side_effect=lambda args, *a, **kw: json.dumps(self.candidate) if '-c' in args else b''), \
|
||||
patch.object(update, 'image_from_archive', side_effect=image), \
|
||||
patch.object(instances, 'command') as command:
|
||||
result = update.resolve_archive(self.desired, b'arch: amd64\n', local_archives=[self.path, other])
|
||||
self.assertEqual(result, (other, DIGEST))
|
||||
self.assertTrue(self.path.exists())
|
||||
command.assert_not_called()
|
||||
|
||||
def test_a_new_digest_downloads_once_instead_of_using_an_old_archive(self):
|
||||
candidate = {'manifest_digest': NEW, 'registry_digest': NEW}
|
||||
cached = self.path.with_name('update.tar')
|
||||
copies = []
|
||||
def run(args, *a, **kwargs):
|
||||
if '-c' in args:
|
||||
return json.dumps(candidate)
|
||||
if args[0] == 'skopeo':
|
||||
copies.append(args)
|
||||
Path(args[-1].removeprefix('oci-archive:')).write_bytes(b'new verified fixture')
|
||||
return b''
|
||||
def image(path):
|
||||
return {'manifest_digest': DIGEST if str(path) == str(self.path) else NEW}
|
||||
with patch.object(update, 'run_quiet', side_effect=run), \
|
||||
patch.object(update, 'image_from_archive', side_effect=image), \
|
||||
patch.object(instances, 'command', return_value=str(cached).encode()), \
|
||||
patch.object(update.work_backup, 'available', return_value=1024**4), \
|
||||
patch.object(update.transaction, 'log'):
|
||||
result = update.resolve_archive(self.desired, b'arch: amd64\n', local_archives=[self.path])
|
||||
self.assertEqual(result, (cached, NEW))
|
||||
self.assertEqual(len(copies), 1)
|
||||
self.assertIn('docker://example/app@'+NEW, copies[0])
|
||||
self.assertEqual(self.path.read_bytes(), b'fixture verified by stub')
|
||||
|
||||
def test_a_broken_or_symlink_original_cache_does_not_hide_a_valid_update_cache(self):
|
||||
cached = self.path.with_name('update.tar')
|
||||
cached.write_bytes(b'valid update fixture')
|
||||
link = self.path.with_name('link.tar')
|
||||
link.symlink_to(self.path)
|
||||
verified = []
|
||||
def run(args, *a, **kwargs):
|
||||
if '-c' in args:
|
||||
return json.dumps(self.candidate)
|
||||
self.assertNotEqual(args[0], 'skopeo')
|
||||
verified.append(Path(args[-1]))
|
||||
if Path(args[-1]) == self.path:
|
||||
raise RuntimeError('corrupt archive')
|
||||
return b''
|
||||
with patch.object(update, 'run_quiet', side_effect=run), \
|
||||
patch.object(update, 'image_from_archive', return_value=self.image), \
|
||||
patch.object(instances, 'command', return_value=str(cached).encode()):
|
||||
result = update.resolve_archive(self.desired, b'arch: amd64\n', local_archives=[link, self.path])
|
||||
self.assertEqual(result, (cached, DIGEST))
|
||||
self.assertNotIn(link, verified)
|
||||
self.assertTrue(link.is_symlink())
|
||||
self.assertTrue(self.path.exists())
|
||||
|
||||
|
||||
class UpdateRoutingTests(unittest.TestCase):
|
||||
def run_update(self, new_image=False, resource_edit=True):
|
||||
saved = record()
|
||||
candidate = copy.deepcopy(saved)
|
||||
candidate['deployment']['resources']['memory_mb'] = 768
|
||||
if not resource_edit:
|
||||
candidate['deployment']['environment'][0]['value'] = 'changed'
|
||||
proposal = {'operation': 'recreate', 'candidate': candidate}
|
||||
import contextlib, oci_operation_notice
|
||||
with patch.object(instances, 'locked', return_value=contextlib.nullcontext()), \
|
||||
patch.object(instances, 'read', return_value=saved), \
|
||||
patch.object(instances, 'command', return_value=CONFIG), \
|
||||
patch.object(update.transaction, 'candidate_contract', return_value=candidate), \
|
||||
patch.object(update.transaction, 'external_changes', return_value={}), \
|
||||
patch.object(update.transaction, 'preflight'), \
|
||||
patch.object(update, 'resolve_archive', return_value=(Path('/new.tar') if new_image or not resource_edit else None, NEW if new_image else DIGEST)) as resolve, \
|
||||
patch.object(resources, 'apply') as apply_resources, \
|
||||
patch.object(update.transaction, 'apply') as apply_image, \
|
||||
patch.object(oci_operation_notice, 'operation', return_value=contextlib.nullcontext()):
|
||||
update.update(115, proposal=proposal)
|
||||
return resolve, apply_resources, apply_image
|
||||
|
||||
def test_unchanged_image_routes_cpu_ram_edits_to_the_existing_guest(self):
|
||||
resolve, native, image = self.run_update()
|
||||
self.assertEqual(resolve.call_args.args[2]['manifest_digest'], DIGEST)
|
||||
native.assert_called_once()
|
||||
image.assert_not_called()
|
||||
|
||||
def test_a_new_image_still_uses_the_normal_verified_recreation(self):
|
||||
_resolve, native, image = self.run_update(new_image=True)
|
||||
native.assert_not_called()
|
||||
image.assert_called_once()
|
||||
|
||||
def test_other_modifications_offer_the_recorded_archive_to_the_resolver(self):
|
||||
resolve, native, image = self.run_update(resource_edit=False)
|
||||
self.assertIsNone(resolve.call_args.args[2])
|
||||
self.assertEqual(resolve.call_args.kwargs['local_archives'], ['/missing/deleted-image.tar'])
|
||||
native.assert_not_called()
|
||||
image.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user