diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index 9612c0d3..feb68709 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -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' diff --git a/lang/de.json b/lang/de.json index 8d643f1a..6b0524e7 100644 --- a/lang/de.json +++ b/lang/de.json @@ -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.", diff --git a/lang/es.json b/lang/es.json index de4d581e..4f8d1903 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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.", diff --git a/lang/fr.json b/lang/fr.json index 2d3be751..c552a567 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -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é.", diff --git a/lang/it.json b/lang/it.json index 13e3c788..2be35ba8 100644 --- a/lang/it.json +++ b/lang/it.json @@ -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.", diff --git a/lang/pt.json b/lang/pt.json index e95b4413..1c0a3622 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -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.", diff --git a/lang/sk.json b/lang/sk.json index c29bb126..5117ff23 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -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.", diff --git a/lang/sv.json b/lang/sv.json index 377b3f16..45d309ff 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -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.", diff --git a/oci/remote/oci_resource_modify.py b/oci/remote/oci_resource_modify.py new file mode 100644 index 00000000..6ae818bc --- /dev/null +++ b/oci/remote/oci_resource_modify.py @@ -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 diff --git a/oci/remote/oci_update_current.py b/oci/remote/oci_update_current.py index bba91c04..4ade01c1 100644 --- a/oci/remote/oci_update_current.py +++ b/oci/remote/oci_update_current.py @@ -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 diff --git a/oci/tests/test_modify_image_reuse.py b/oci/tests/test_modify_image_reuse.py new file mode 100644 index 00000000..03020ced --- /dev/null +++ b/oci/tests/test_modify_image_reuse.py @@ -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()