Merge pull request #423 from Vaso73/fix/oci-stack-recreate

fix(oci): recreate coordinated stacks from saved state
This commit is contained in:
MacRimi
2026-10-06 19:12:28 +02:00
committed by GitHub
19 changed files with 396 additions and 59 deletions
@@ -0,0 +1,74 @@
"""Keep the Monitor OCI editor path distinct from coordinated stack recreation."""
import ast
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest import TestCase
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
CLI = ROOT / 'oci/src/proxmenux_oci/cli.py'
MONITOR = ROOT / 'AppImage/components/virtual-machines.tsx'
WRAPPER = ROOT / 'scripts/oci/manage_instance.sh'
EXTRA_DEVICES = ROOT / 'oci/src/proxmenux_oci/extra_devices.py'
STACK_RECREATION = ROOT / 'oci/src/proxmenux_oci/stack_recreation.py'
def extracted_stack_manager(scope):
source = MENU.read_text(encoding='utf-8').replace(
' from .stack_recreation import modify_stack\n', '')
node = next(item for item in ast.parse(source).body
if isinstance(item, ast.FunctionDef) and item.name == '_manage_stack')
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
str(MENU), 'exec'), scope)
return scope['_manage_stack']
class MonitorModifyContractTests(TestCase):
def test_monitor_sends_modify_for_a_stack_and_recreate_for_a_single_instance(self):
source = MONITOR.read_text(encoding='utf-8')
self.assertIn('action: "update" | "modify" | "recreate" | "recover"', source)
self.assertIn('action: ociInstance.stack ? "modify" : "recreate"', source)
def test_cli_and_monitor_wrapper_accept_the_modify_action(self):
self.assertIn('choices=("update", "modify", "recreate")', CLI.read_text(encoding='utf-8'))
self.assertIn('${ACTION:-} != "modify"', WRAPPER.read_text(encoding='utf-8'))
def test_modify_device_filter_matches_the_device_prompt_contract(self):
caller = STACK_RECREATION.read_text(encoding='utf-8')
device_prompt = EXTRA_DEVICES.read_text(encoding='utf-8')
prompt = next(node for node in ast.parse(device_prompt).body
if isinstance(node, ast.FunctionDef) and node.name == 'ask_extra_devices')
self.assertIn('kinds', [argument.arg for argument in prompt.args.args])
self.assertIn("ask_extra_devices(ui, attached, True, kinds=", caller)
def test_modify_action_opens_the_stack_editor_without_running_lifecycle(self):
primary = {'stack': {'members': [{'vmid': 101}]}}
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
instances.read = lambda *_: primary
modify_stack = Mock(return_value=True)
run_lifecycle = Mock()
manager = extracted_stack_manager({
'sys': SimpleNamespace(path=[]),
'Path': Path,
'modify_stack': modify_stack,
'_run_lifecycle': run_lifecycle,
})
ui = SimpleNamespace()
with patch.dict(sys.modules, {
'oci_instances': instances,
'oci_stack_replay': ModuleType('oci_stack_replay'),
}):
result = manager(Path('/inert'), ui, {'vmid': 101}, action='modify')
self.assertTrue(result)
modify_stack.assert_called_once_with(Path('/inert'), ui, primary, run_lifecycle)
run_lifecycle.assert_not_called()
if __name__ == '__main__':
import unittest
unittest.main()
@@ -69,7 +69,7 @@ class SelectionSetupWording(TestCase):
# It cannot be updated, but it can still be changed or removed.
ui = self._stack({'members': [{'native_stack_intent': {'adapt': True}}]}, REPLAY)
ui.choose.assert_called_once()
self.assertEqual([tag for tag, _ in ui.choose.call_args.args[1]], ['recreate', 'remove'])
self.assertEqual([tag for tag, _ in ui.choose.call_args.args[1]], ['modify', 'remove'])
def _stack(self, stack, expected):
record = {'stack': stack}
+5 -2
View File
@@ -954,7 +954,7 @@ export function VirtualMachines() {
// A container restored from a backup carries the mark of its installation
// and has no record on this host until it is recovered from the menu.
const [ociRestored, setOciRestored] = useState(false)
const [ociAction, setOciAction] = useState<{ vmid: number; action: "update" | "recreate" | "recover" } | null>(null)
const [ociAction, setOciAction] = useState<{ vmid: number; action: "update" | "modify" | "recreate" | "recover" } | null>(null)
// Firewall log state — fetched only when the operator opens that tab
// so a CT/VM without firewall use doesn't pay the pvesh cost on every
@@ -5341,7 +5341,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<Button
size="sm"
className={neutralBtnCls}
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "recreate" })}
onClick={() => setOciAction({
vmid: selectedVM.vmid,
action: ociInstance.stack ? "modify" : "recreate",
})}
>
<RotateCcw className="h-4 w-4 mr-1.5" />
{t("vmLxc.ociUpdates.recreate")}
+13 -1
View File
@@ -7856,5 +7856,17 @@
"⚠ Disk data will NOT be erased.": "⚠ Festplattendaten werden NICHT gelöscht.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Die Festplatte wird ausgehängt und aus /etc/fstab entfernt.",
"⚠ The /etc/fstab entry will be removed.": "⚠ Der /etc/fstab-Eintrag wird entfernt.",
"⚠ The disk will be unmounted.": "⚠ Die Festplatte wird ausgehängt."
"⚠ The disk will be unmounted.": "⚠ Die Festplatte wird ausgehängt.",
"Apply these changes?": "Apply these changes?",
"All {count} containers of the application will be recreated from their saved image digests (main CT: {vmid}). The stack is stopped, every container is backed up and replaced, then checked. If anything fails, the backups are restored.": "All {count} containers of the application will be recreated from their saved image digests (main CT: {vmid}). The stack is stopped, every container is backed up and replaced, then checked. If anything fails, the backups are restored.",
"Checking the stack before recreating...": "Checking the stack before recreating...",
"Invalid saved image digest": "Invalid saved image digest",
"Modify extra paths and devices": "Modify extra paths and devices",
"Modify OCI stack": "Modify OCI stack",
"Recreate every container with its saved configuration": "Recreate every container with its saved configuration",
"Recreate OCI stack": "Recreate OCI stack",
"Recreate the whole stack?": "Recreate the whole stack?",
"Recreating": "Recreating",
"Stack recreation completed. Data kept.": "Stack recreation completed. Data kept.",
"The registry did not return the saved image digest": "The registry did not return the saved image digest"
}
+13 -1
View File
@@ -7864,5 +7864,17 @@
"⚠ Disk data will NOT be erased.": "⚠ Los datos del disco NO se borrarán.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ El disco se desmontará y se eliminará de /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ Se eliminará la entrada /etc/fstab.",
"⚠ The disk will be unmounted.": "⚠ El disco se desmontará."
"⚠ The disk will be unmounted.": "⚠ El disco se desmontará.",
"Apply these changes?": "Apply these changes?",
"All {count} containers of the application will be recreated from their saved image digests (main CT: {vmid}). The stack is stopped, every container is backed up and replaced, then checked. If anything fails, the backups are restored.": "All {count} containers of the application will be recreated from their saved image digests (main CT: {vmid}). The stack is stopped, every container is backed up and replaced, then checked. If anything fails, the backups are restored.",
"Checking the stack before recreating...": "Checking the stack before recreating...",
"Invalid saved image digest": "Invalid saved image digest",
"Modify extra paths and devices": "Modify extra paths and devices",
"Modify OCI stack": "Modify OCI stack",
"Recreate every container with its saved configuration": "Recreate every container with its saved configuration",
"Recreate OCI stack": "Recreate OCI stack",
"Recreate the whole stack?": "Recreate the whole stack?",
"Recreating": "Recreating",
"Stack recreation completed. Data kept.": "Stack recreation completed. Data kept.",
"The registry did not return the saved image digest": "The registry did not return the saved image digest"
}
+13 -1
View File
@@ -7855,5 +7855,17 @@
"⚠ Disk data will NOT be erased.": "⚠ Les données du disque ne seront PAS effacées.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Le disque sera démonté et supprimé de /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ L'entrée /etc/fstab sera supprimée.",
"⚠ The disk will be unmounted.": "⚠ Le disque sera démonté."
"⚠ The disk will be unmounted.": "⚠ Le disque sera démonté.",
"Apply these changes?": "Apply these changes?",
"All {count} containers of the application will be recreated from their saved image digests (main CT: {vmid}). The stack is stopped, every container is backed up and replaced, then checked. If anything fails, the backups are restored.": "All {count} containers of the application will be recreated from their saved image digests (main CT: {vmid}). The stack is stopped, every container is backed up and replaced, then checked. If anything fails, the backups are restored.",
"Checking the stack before recreating...": "Checking the stack before recreating...",
"Invalid saved image digest": "Invalid saved image digest",
"Modify extra paths and devices": "Modify extra paths and devices",
"Modify OCI stack": "Modify OCI stack",
"Recreate every container with its saved configuration": "Recreate every container with its saved configuration",
"Recreate OCI stack": "Recreate OCI stack",
"Recreate the whole stack?": "Recreate the whole stack?",
"Recreating": "Recreating",
"Stack recreation completed. Data kept.": "Stack recreation completed. Data kept.",
"The registry did not return the saved image digest": "The registry did not return the saved image digest"
}
+18 -1
View File
@@ -7856,5 +7856,22 @@
"⚠ Disk data will NOT be erased.": "⚠ I dati del disco NON verranno cancellati.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Il disco verrà smontato e rimosso da /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ La voce /etc/fstab verrà rimossa.",
"⚠ The disk will be unmounted.": "⚠ Il disco verrà smontato."
"⚠ The disk will be unmounted.": "⚠ Il disco verrà smontato.",
"OCI containers keep their configuration: they are stopped and their Start on boot is disabled. Their GPU is removed from OCI manager Apps, with Recreate.": "I container OCI mantengono la configurazione: vengono arrestati e l'avvio automatico viene disabilitato. Rimuovi la GPU da OCI manager Apps con Ricrea.",
"OCI container, configuration kept. Remove its GPU from OCI manager Apps, with Recreate: CT": "Container OCI, configurazione mantenuta. Rimuovi la GPU da OCI manager Apps con Ricrea: CT",
"Containers created from an OCI image are not listed: their paths are changed in OCI manager Apps (Recreate).": "I container creati da un'immagine OCI non sono elencati: modifica i percorsi in OCI manager Apps (Ricrea).",
"Containers created from an OCI image are not listed: their devices are changed in OCI manager Apps (Recreate).": "I container creati da un'immagine OCI non sono elencati: modifica i dispositivi in OCI manager Apps (Ricrea).",
"Containers created from an OCI image are not listed.": "I container creati da un'immagine OCI non sono elencati.",
"Apply these changes?": "Applicare queste modifiche?",
"All {count} containers of the application will be recreated from their saved image digests (main CT: {vmid}). The stack is stopped, every container is backed up and replaced, then checked. If anything fails, the backups are restored.": "Tutti i {count} container dell'applicazione verranno ricreati dai digest delle immagini salvati (CT principale: {vmid}). Lo stack viene arrestato, ogni container viene sottoposto a backup e sostituito, quindi controllato. Se qualcosa non riesce, i backup vengono ripristinati.",
"Checking the stack before recreating...": "Controllo dello stack prima della ricreazione...",
"Invalid saved image digest": "Digest dell'immagine salvato non valido",
"Modify extra paths and devices": "Modifica percorsi aggiuntivi e dispositivi",
"Modify OCI stack": "Modifica stack OCI",
"Recreate every container with its saved configuration": "Ricrea ogni container con la configurazione salvata",
"Recreate OCI stack": "Ricrea stack OCI",
"Recreate the whole stack?": "Ricreare l'intero stack?",
"Recreating": "Ricreazione in corso",
"Stack recreation completed. Data kept.": "Ricreazione dello stack completata. Dati mantenuti.",
"The registry did not return the saved image digest": "Il registro non ha restituito il digest dell'immagine salvato"
}
+13 -1
View File
@@ -7856,5 +7856,17 @@
"⚠ Disk data will NOT be erased.": "⚠ Os dados do disco NÃO serão apagados.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ O disco será desmontado e removido de /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ A entrada /etc/fstab será removida.",
"⚠ The disk will be unmounted.": "⚠ O disco será desmontado."
"⚠ The disk will be unmounted.": "⚠ O disco será desmontado.",
"Apply these changes?": "Apply these changes?",
"All {count} containers of the application will be recreated from their saved image digests (main CT: {vmid}). The stack is stopped, every container is backed up and replaced, then checked. If anything fails, the backups are restored.": "All {count} containers of the application will be recreated from their saved image digests (main CT: {vmid}). The stack is stopped, every container is backed up and replaced, then checked. If anything fails, the backups are restored.",
"Checking the stack before recreating...": "Checking the stack before recreating...",
"Invalid saved image digest": "Invalid saved image digest",
"Modify extra paths and devices": "Modify extra paths and devices",
"Modify OCI stack": "Modify OCI stack",
"Recreate every container with its saved configuration": "Recreate every container with its saved configuration",
"Recreate OCI stack": "Recreate OCI stack",
"Recreate the whole stack?": "Recreate the whole stack?",
"Recreating": "Recreating",
"Stack recreation completed. Data kept.": "Stack recreation completed. Data kept.",
"The registry did not return the saved image digest": "The registry did not return the saved image digest"
}
+13 -1
View File
@@ -7855,5 +7855,17 @@
"⚠ Disk data will NOT be erased.": "⚠ Dáta na disku sa NEVYMAŽÚ.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Disk sa odpojí a odstráni z /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ Záznam v /etc/fstab bude odstránený.",
"⚠ The disk will be unmounted.": "⚠ Disk bude odpojený."
"⚠ The disk will be unmounted.": "⚠ Disk bude odpojený.",
"Apply these changes?": "Použiť tieto zmeny?",
"All {count} containers of the application will be recreated from their saved image digests (main CT: {vmid}). The stack is stopped, every container is backed up and replaced, then checked. If anything fails, the backups are restored.": "Všetkých {count} kontajnerov aplikácie sa znovu vytvorí z uložených image digestov (hlavný CT: {vmid}). Stack sa zastaví, každý kontajner sa zazálohuje, nahradí a skontroluje. Ak niečo zlyhá, zálohy sa obnovia.",
"Checking the stack before recreating...": "Kontrolujem stack pred opätovným vytvorením...",
"Invalid saved image digest": "Uložený image digest nie je platný",
"Modify extra paths and devices": "Upraviť prídavné cesty a zariadenia",
"Modify OCI stack": "Upraviť OCI stack",
"Recreate every container with its saved configuration": "Znovu vytvoriť každý kontajner s uloženými nastaveniami",
"Recreate OCI stack": "Znovu vytvoriť OCI stack",
"Recreate the whole stack?": "Znovu vytvoriť celý stack?",
"Recreating": "Znovu vytváram",
"Stack recreation completed. Data kept.": "Opätovné vytvorenie stacku je hotové. Dáta zostali zachované.",
"The registry did not return the saved image digest": "Register nevrátil uložený image digest"
}
+13 -1
View File
@@ -7856,5 +7856,17 @@
"⚠ Disk data will NOT be erased.": "⚠ Diskdata kommer INTE att raderas.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Disken kommer att avmonteras och tas bort från /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ /etc/fstab-posten kommer att tas bort.",
"⚠ The disk will be unmounted.": "⚠ Disken kommer att avmonteras."
"⚠ The disk will be unmounted.": "⚠ Disken kommer att avmonteras.",
"Apply these changes?": "Apply these changes?",
"All {count} containers of the application will be recreated from their saved image digests (main CT: {vmid}). The stack is stopped, every container is backed up and replaced, then checked. If anything fails, the backups are restored.": "All {count} containers of the application will be recreated from their saved image digests (main CT: {vmid}). The stack is stopped, every container is backed up and replaced, then checked. If anything fails, the backups are restored.",
"Checking the stack before recreating...": "Checking the stack before recreating...",
"Invalid saved image digest": "Invalid saved image digest",
"Modify extra paths and devices": "Modify extra paths and devices",
"Modify OCI stack": "Modify OCI stack",
"Recreate every container with its saved configuration": "Recreate every container with its saved configuration",
"Recreate OCI stack": "Recreate OCI stack",
"Recreate the whole stack?": "Recreate the whole stack?",
"Recreating": "Recreating",
"Stack recreation completed. Data kept.": "Stack recreation completed. Data kept.",
"The registry did not return the saved image digest": "The registry did not return the saved image digest"
}
+3 -1
View File
@@ -166,6 +166,7 @@ def resolve_candidate(reference, architecture):
if '@' in reference:
transport = repo + '@' + reference.split('@', 1)[1]
raw = command('skopeo', 'inspect', '--raw', 'docker://' + transport)
registry_digest = 'sha256:' + sha(raw)
manifest = json.loads(raw)
if 'manifests' in manifest:
matches = [m for m in manifest['manifests'] if m.get('platform', {}).get('architecture') == architecture
@@ -192,7 +193,8 @@ def resolve_candidate(reference, architecture):
# The build date identifies the image as the publisher released it: it is
# what changes when an image is rebuilt, whether or not the application
# version inside it moved.
return {'manifest_digest': digest, 'layers': [layer['digest'] for layer in json.loads(raw).get('layers', [])],
return {'manifest_digest': digest, 'registry_digest': registry_digest,
'layers': [layer['digest'] for layer in json.loads(raw).get('layers', [])],
'defaults': defaults, 'version': version, 'created': config.get('created')}
+38 -20
View File
@@ -32,7 +32,7 @@ def validate_database_transition(previous, candidate):
raise ValueError(translate('The new image changes the PostgreSQL major version; the data must be migrated before updating'))
def nextcloud_plan(primary, records, inventory, lifecycle):
def nextcloud_plan(primary, records, inventory, lifecycle, operation):
"""Translate only the known three-member stack and retain rollback contracts."""
intent = primary.get('native_stack_intent', {})
if intent.get('adapter', {}).get('name') != 'install_nextcloud_stack.sh':
@@ -60,13 +60,13 @@ def nextcloud_plan(primary, records, inventory, lifecycle):
snapshot = copy.deepcopy(translated[service['vmid']])
snapshot.pop('stack', None)
parent['stack']['members'].append(snapshot)
plan = oci_stack_plan.build(parent, translated, inventory, 'update')
plan = oci_stack_plan.build(parent, translated, inventory, operation)
plan['original_members'] = copy.deepcopy(list(records.values()))
plan['nextcloud_replay'] = True
return plan
def paperless_plan(primary, records, inventory, lifecycle):
def paperless_plan(primary, records, inventory, lifecycle, operation):
"""Prepare the known Paperless stack without publishing translated recipes."""
if primary.get('native_stack_intent', {}).get('adapter', {}).get('name') != 'install_paperless_stack.sh':
raise ValueError(f"{translate('Unrecognized stack adapter:')} Paperless")
@@ -89,13 +89,13 @@ def paperless_plan(primary, records, inventory, lifecycle):
snapshot = copy.deepcopy(translated[service['vmid']])
snapshot.pop('stack', None)
parent['stack']['members'].append(snapshot)
plan = oci_stack_plan.build(parent, translated, inventory, 'update')
plan = oci_stack_plan.build(parent, translated, inventory, operation)
plan['original_members'] = copy.deepcopy(list(records.values()))
plan['paperless_replay'] = True
return plan
def tandoor_plan(primary, records, inventory, lifecycle):
def tandoor_plan(primary, records, inventory, lifecycle, operation):
"""Prepare exactly the application and PostgreSQL without publishing state."""
if primary.get('native_stack_intent', {}).get('adapter', {}).get('name') != 'install_tandoor_stack.sh':
raise ValueError(f"{translate('Unrecognized stack adapter:')} Tandoor")
@@ -118,13 +118,13 @@ def tandoor_plan(primary, records, inventory, lifecycle):
snapshot = copy.deepcopy(translated[service['vmid']])
snapshot.pop('stack', None)
parent['stack']['members'].append(snapshot)
plan = oci_stack_plan.build(parent, translated, inventory, 'update')
plan = oci_stack_plan.build(parent, translated, inventory, operation)
plan['original_members'] = copy.deepcopy(list(records.values()))
plan['tandoor_replay'] = True
return plan
def immich_plan(primary, records, inventory, lifecycle):
def immich_plan(primary, records, inventory, lifecycle, operation):
if primary.get('native_stack_intent', {}).get('adapter', {}).get('name') != 'install_immich_stack.sh':
raise ValueError(f"{translate('Unrecognized stack adapter:')} Immich")
translated = {vmid: replay.immich_record(record) for vmid, record in records.items()}
@@ -145,7 +145,7 @@ def immich_plan(primary, records, inventory, lifecycle):
snapshot = copy.deepcopy(translated[service['vmid']])
snapshot.pop('stack', None)
parent['stack']['members'].append(snapshot)
plan = oci_stack_plan.build(parent, translated, inventory, 'update')
plan = oci_stack_plan.build(parent, translated, inventory, operation)
plan['original_members'] = copy.deepcopy(list(records.values()))
plan['immich_replay'] = True
return plan
@@ -262,7 +262,11 @@ class NativeAdapter:
current['pending_stack_transaction'] = str(self.journal)
instances.write(instances.location(self.root, vmid), current)
config = instances.command('pct', 'config', str(record['vmid']))
archive, digest = resolve_archive(record, config)
if operation == 'recreate':
digest = record.get('observed', {}).get('resolved_registry_digest')
archive, digest = resolve_archive(record, config, required_digest=digest)
else:
archive, digest = resolve_archive(record, config)
image = image_from_archive(str(archive))
old = record['observed']['image']
validate_database_transition(old, image)
@@ -279,7 +283,13 @@ class NativeAdapter:
msg_info(f"{translate('Checking the new image without starting it:')} {self.describe(record['vmid'])}")
self.probe_nextcloud_image(record, archive, image)
msg_ok(f"{translate('New image compatible:')} {self.describe(record['vmid'])}")
return {'archive': str(archive), 'digest': digest}
proposal = None
if operation == 'recreate':
proposal = {'operation': 'recreate', 'candidate': copy.deepcopy(record)}
config_hash = record.get('observed', {}).get('config_sha256')
if config_hash:
proposal['base_config_sha256'] = config_hash
return {'archive': str(archive), 'digest': digest, 'proposal': proposal}
def probe_nextcloud_image(self, record, archive, image):
"""Import but never start a disposable rootfs before stopping the stack."""
@@ -346,9 +356,12 @@ class NativeAdapter:
context.update(tandoor_replay=True, effective_record=self.records[vmid])
if self.plan.get('immich_replay'):
context.update(immich_replay=True, effective_record=self.records[vmid])
member_tx.apply(self.root, vmid, Path(prepared['archive']), 'update',
registry_digest=prepared['digest'], acknowledge_external_data=self.acknowledge,
coordinated=context, progress=f"{translate('Updating')} {self.describe(vmid)}:")
operation = self.plan['operation']
member_tx.apply(self.root, vmid, Path(prepared['archive']), operation,
proposal=prepared.get('proposal'), registry_digest=prepared['digest'],
acknowledge_external_data=self.acknowledge, coordinated=context,
progress=f"{translate('Recreating') if operation == 'recreate' else translate('Updating')} "
f"{self.describe(vmid)}:")
def start(self, vmid):
self.validate(self.plan)
@@ -617,10 +630,13 @@ class NativeAdapter:
_current = {'journal': None, 'primary': None}
def run(vmid, recover=False, acknowledge_external_data=False, keep_backup=None):
def run(vmid, recover=False, acknowledge_external_data=False, keep_backup=None, operation='update'):
if operation not in ('update', 'recreate'):
raise ValueError(translate('Invalid stack operation'))
root = instances.ROOT
msg_info(translate('Checking the interrupted stack operation...') if recover
else translate('Checking the stack before the update...'))
else (translate('Checking the stack before recreating...') if operation == 'recreate'
else translate('Checking the stack before the update...')))
with instances.locked(root):
selected = instances.read(root, vmid)
primary_id = selected.get('stack_member', {}).get('primary_vmid', vmid)
@@ -665,9 +681,9 @@ def run(vmid, recover=False, acknowledge_external_data=False, keep_backup=None):
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077:
raise ValueError(translate('Unsafe dependency contract'))
builder = builders[adapter_name]
plan = builder(primary, records, inventory, json.loads(lifecycle.read_text()))
plan = builder(primary, records, inventory, json.loads(lifecycle.read_text()), operation)
else:
plan = oci_stack_plan.build(primary, records, inventory, 'update')
plan = oci_stack_plan.build(primary, records, inventory, operation)
stack_tx.validate_plan(plan)
directory = instances.location(root, primary_id).parent / 'stack-transactions' / uuid.uuid4().hex
private_directory(directory)
@@ -689,10 +705,11 @@ def run(vmid, recover=False, acknowledge_external_data=False, keep_backup=None):
adapter.keep_backup = keep_backup
import oci_operation_notice
import oci_update_current
with oci_operation_notice.operation([member['vmid'] for member in plan['members']], 'update',
with oci_operation_notice.operation([member['vmid'] for member in plan['members']], operation,
oci_update_current.application_name(primary, primary_id), primary_id):
result = stack_tx.execute(journal, adapter, plan)
msg_ok(translate('Stack update completed. Data kept.'))
msg_ok(translate('Stack recreation completed. Data kept.') if operation == 'recreate'
else translate('Stack update completed. Data kept.'))
return result
@@ -728,13 +745,14 @@ def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('vmid', type=int)
parser.add_argument('--recover', action='store_true')
parser.add_argument('--operation', choices=['update', 'recreate'], default='update')
parser.add_argument('--acknowledge-external-data', action='store_true')
parser.add_argument('--keep-backup', metavar='STORAGE')
args = parser.parse_args()
if os.geteuid() != 0:
parser.error(translate('Root privileges on the Proxmox node are required'))
try:
run(args.vmid, args.recover, args.acknowledge_external_data, args.keep_backup)
run(args.vmid, args.recover, args.acknowledge_external_data, args.keep_backup, args.operation)
return 0
except BlockingIOError:
msg_error(translate('Another OCI operation is using the registry. This operation was not started.'))
+10 -2
View File
@@ -50,20 +50,28 @@ def run_quiet(args, error, capture=False):
process.wait()
def resolve_archive(desired, config, current=None, check=None):
def resolve_archive(desired, config, current=None, check=None, required_digest=None):
# 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']
architecture = transaction.parse_config(config)['arch']
msg_info(translate('Checking the image in the registry...'))
transaction.log(f'image: {reference} ({architecture})')
if required_digest is not None and not re.fullmatch(r'sha256:[a-f0-9]{64}', required_digest):
raise ValueError(translate('Invalid saved image digest'))
lookup = repository(reference) + '@' + required_digest if required_digest else reference
code = ('import json,sys; from oci_installation_state import resolve_candidate; '
'print(json.dumps(resolve_candidate(sys.argv[1],sys.argv[2])))')
candidate = json.loads(run_quiet([sys.executable, '-c', code, reference, architecture],
candidate = json.loads(run_quiet([sys.executable, '-c', code, lookup, architecture],
translate('Could not query the image registry'), capture=True))
digest = candidate['manifest_digest']
if not re.fullmatch(r'sha256:[a-f0-9]{64}', digest):
raise ValueError(translate('Invalid registry digest'))
registry_digest = candidate.get('registry_digest')
if registry_digest is not None and not re.fullmatch(r'sha256:[a-f0-9]{64}', registry_digest):
raise ValueError(translate('Invalid registry digest'))
if required_digest is not None and registry_digest != required_digest:
raise ValueError(translate('The registry did not return the saved image digest'))
msg_ok(f"{translate('Image:')} {reference} ({candidate.get('version') or digest[7:19]})")
if current and same_image(candidate, current):
return None, digest
+2 -2
View File
@@ -595,9 +595,9 @@ def build_parser() -> argparse.ArgumentParser:
rclone_parser.add_argument("--host", default="auto")
rclone_parser.add_argument("--dry-run", action="store_true")
subparsers.add_parser("recover", help="Register again the OCI applications restored from a backup")
manage_parser = subparsers.add_parser("manage", help="Update or recreate one installed OCI instance")
manage_parser = subparsers.add_parser("manage", help="Update, modify or recreate one installed OCI instance")
manage_parser.add_argument("vmid", type=int)
manage_parser.add_argument("--action", choices=("update", "recreate"), required=True)
manage_parser.add_argument("--action", choices=("update", "modify", "recreate"), required=True)
manage_parser.add_argument("--keep-backup", metavar="STORAGE",
help="Keep the backup taken before the update in this Proxmox storage")
manage_parser.add_argument("--unattended", action="store_true",
+26 -12
View File
@@ -285,6 +285,8 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()):
return False
if row['stack']:
return _manage_stack(project, ui, row, action, lifecycle_args)
if action == 'modify':
return False
if not row['pending']:
if row['status'] != 'installed' or row['reason'] != 'matched':
ui.message(translate('The instance identity or status must be reviewed before updating.'), translate('OCI management'))
@@ -483,24 +485,32 @@ def _manage_stack(project, ui, row, action=None, lifecycle_args=()):
if action is None:
# A stack that cannot be updated can still be removed.
options = [('update', translate('Update every container of the application'))] if updatable else []
options.append(('recreate', translate('Recreate: add or remove extra paths and devices')))
options.append(('modify', translate('Modify extra paths and devices')))
if updatable:
options.append(('recreate', translate('Recreate every container with its saved configuration')))
options.append(('remove', translate('Remove: delete the application and its containers')))
action = ui.choose(translate('Manage OCI stack'), options, options[0][0])
if action is None:
return False
if action == 'remove':
return _remove(project, ui, primary_id)
if action == 'modify':
from .stack_recreation import modify_stack
return modify_stack(project, ui, primary, _run_lifecycle)
if action == 'recreate':
# Nothing is rebuilt: only the extra paths and devices of the
# application container change.
from .stack_recreation import recreate_stack
return recreate_stack(project, ui, primary, _run_lifecycle)
if not ui.review(translate('All {count} containers of the application are updated together (main CT: {vmid}). '
'If there are new versions, all images are downloaded and verified, the application '
'is stopped, each container is backed up and replaced with its new image. If anything '
'fails, the backups are restored.').format(count=len(members), vmid=primary_id),
translate('Update OCI stack'), question=translate('Update the whole stack?'), default=True):
return False
if not ui.review(translate('All {count} containers of the application will be recreated from their saved '
'image digests (main CT: {vmid}). The stack is stopped, every container is '
'backed up and replaced, then checked. If anything fails, the backups are restored.')
.format(count=len(members), vmid=primary_id), translate('Recreate OCI stack'),
question=translate('Recreate the whole stack?'), default=False):
return False
else:
if not ui.review(translate('All {count} containers of the application are updated together (main CT: {vmid}). '
'If there are new versions, all images are downloaded and verified, the application '
'is stopped, each container is backed up and replaced with its new image. If anything '
'fails, the backups are restored.').format(count=len(members), vmid=primary_id),
translate('Update OCI stack'), question=translate('Update the whole stack?'), default=True):
return False
else:
if not ui.review(translate('A coordinated operation has a saved journal. Continuing attempts to recover the previous stack where needed, or finish cleanup for a completed operation. Recovery or cleanup can fail.'), translate('Recover OCI stack'),
question=translate('Recover or complete the operation?'), default=True):
@@ -512,9 +522,13 @@ def _manage_stack(project, ui, row, action=None, lifecycle_args=()):
command.append('--recover')
else:
command.extend(lifecycle_args)
if action == 'recreate':
command.extend(['--operation', 'recreate'])
if '--acknowledge-external-data' not in command:
command.append('--acknowledge-external-data')
completed = _run_lifecycle(command, translate('Recover OCI stack') if pending else translate('Update OCI stack'))
title = (translate('Recover OCI stack') if pending else translate('Recreate OCI stack')
if action == 'recreate' else translate('Update OCI stack'))
completed = _run_lifecycle(command, title)
if completed and not pending and not getattr(ui, 'unattended', False):
images.offer_removal(ui, [int(member['vmid']) for member in members])
return completed
+9 -8
View File
@@ -1,6 +1,7 @@
"""Recreate for a multi-container application: add or remove the extra paths
and devices of its application container. Its own data, its database and the
other containers are never part of it."""
"""Modify the extra paths and devices of a multi-container application.
Only its application container is restarted. Its own data, its database and
the other containers are never part of this operation."""
from __future__ import annotations
import json
@@ -206,7 +207,7 @@ def change_recognition(project, ui, primary, member, run_lifecycle):
return run_lifecycle(command, translate('Recreate OCI'))
def recreate_stack(project, ui, primary, run_lifecycle):
def modify_stack(project, ui, primary, run_lifecycle):
learning = immich_learning(primary)
if learning is not None:
what = ui.choose(translate('What to recreate'),
@@ -230,13 +231,13 @@ def recreate_stack(project, ui, primary, run_lifecycle):
member = next(m for m in members if str(m['vmid']) == selected)
changes = plan_changes(ui, member)
if changes is None:
ui.message(translate('Nothing was changed.'), translate('Recreate OCI'))
ui.message(translate('Nothing was changed.'), translate('Modify OCI stack'))
return False
if not ui.review(summary(member, changes), translate('Recreate OCI'),
question=translate('Recreate with these options?'), default=True):
if not ui.review(summary(member, changes), translate('Modify OCI stack'),
question=translate('Apply these changes?'), default=True):
return False
with tempfile.NamedTemporaryFile(mode='w', suffix='.json') as file:
json.dump(changes, file)
file.flush()
return run_lifecycle([sys.executable, str(project / 'remote/oci_stack_modify.py'),
str(member['vmid']), '--changes', file.name], translate('Recreate OCI'))
str(member['vmid']), '--changes', file.name], translate('Modify OCI stack'))
@@ -0,0 +1,58 @@
"""A Recreate pins the registry/index digest while downloading a platform manifest."""
import json
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
import oci_installation_state as state
import oci_update_current as update
def digest(data):
return "sha256:" + state.sha(data)
class RecreateRegistryDigestTests(unittest.TestCase):
def test_candidate_keeps_the_multi_arch_registry_digest_separate_from_platform_manifest(self):
platform = json.dumps({
"schemaVersion": 2,
"config": {"digest": "sha256:" + "c" * 64},
"layers": [],
}).encode()
index = json.dumps({
"schemaVersion": 2,
"manifests": [{"digest": digest(platform),
"platform": {"architecture": "amd64", "os": "linux"}}],
}).encode()
config = json.dumps({"architecture": "amd64", "os": "linux",
"config": {"Labels": {}, "Env": []}}).encode()
with patch.object(state, "command", side_effect=[index, platform, config]):
candidate = state.resolve_candidate("postgres@" + digest(index), "amd64")
self.assertEqual(candidate["registry_digest"], digest(index))
self.assertEqual(candidate["manifest_digest"], digest(platform))
def test_recreate_accepts_saved_index_digest_and_returns_platform_manifest(self):
index_digest = "sha256:" + "a" * 64
platform_digest = "sha256:" + "b" * 64
candidate = {"registry_digest": index_digest, "manifest_digest": platform_digest,
"layers": [], "created": "2026-10-05T00:00:00Z"}
desired = {"template": {"container_contract": {"image": {"reference": "postgres:latest"}}},
"deployment": {"template_storage": "local"}}
with patch.object(update, "run_quiet", return_value=json.dumps(candidate)) as query:
archive, digest_value = update.resolve_archive(
desired, b"arch: amd64\n", current={"manifest_digest": platform_digest},
required_digest=index_digest)
self.assertIsNone(archive)
self.assertEqual(digest_value, platform_digest)
self.assertIn("postgres@" + index_digest, query.call_args.args[0])
if __name__ == "__main__":
unittest.main()
+70
View File
@@ -0,0 +1,70 @@
"""The coordinated Recreate path must remain distinct from Update."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
import oci_stack_native as native
DIGEST = "sha256:" + "a" * 64
MEMBER = {
"vmid": 138,
"installation_id": "blinko-installation",
"template": {"id": "blinko", "container_contract": {"image": {"reference": "blinkospace/blinko:latest"}}},
"deployment": {"mounts": []},
"observed": {"config_sha256": "saved-config", "resolved_registry_digest": DIGEST,
"image": {"architecture": "amd64", "os": "linux", "defaults": {}}},
"stack": {"deployment": {"services": [{"vmid": 138, "name": "Blinko",
"healthcheck": {"type": "running", "timeout_seconds": 120}}]}},
}
class StackRecreateV1Tests(unittest.TestCase):
def adapter(self):
plan = {"operation": "recreate", "primary_vmid": 138, "members": [MEMBER]}
return native.NativeAdapter(Path("/registry"), Path("/journal"), plan)
def test_prepare_resolves_the_saved_digest_and_builds_an_unchanged_proposal(self):
adapter = self.adapter()
image = {"architecture": "amd64", "os": "linux", "defaults": {}}
with patch.object(native.instances, "read", return_value={}), \
patch.object(native.instances, "write"), \
patch.object(native.instances, "location", return_value=Path("/registry/138.json")), \
patch.object(native.instances, "command", return_value=b"arch: amd64\n"), \
patch.object(native, "resolve_archive", return_value=(Path("/cache/blinko.tar"), DIGEST)) as resolve, \
patch.object(native, "image_from_archive", return_value=image):
prepared = adapter.prepare(MEMBER, "recreate")
resolve.assert_called_once_with(MEMBER, b"arch: amd64\n", required_digest=DIGEST)
self.assertEqual(prepared["digest"], DIGEST)
self.assertEqual(prepared["proposal"]["operation"], "recreate")
self.assertEqual(prepared["proposal"]["candidate"], MEMBER)
self.assertEqual(prepared["proposal"]["base_config_sha256"], "saved-config")
def test_replace_passes_the_recreate_operation_and_proposal_to_every_member(self):
adapter = self.adapter()
prepared = {"archive": "/cache/blinko.tar", "digest": DIGEST,
"proposal": {"operation": "recreate", "candidate": MEMBER,
"base_config_sha256": "saved-config"}}
with patch.object(adapter, "validate"), \
patch.object(adapter, "state", return_value={"backups": {"138": {"archive": "/backup"}}}), \
patch.object(native.member_tx, "apply") as apply:
adapter.replace(138, prepared, "transaction-id")
self.assertEqual(apply.call_args.args[3], "recreate")
self.assertEqual(apply.call_args.kwargs["proposal"], prepared["proposal"])
self.assertEqual(apply.call_args.kwargs["registry_digest"], DIGEST)
self.assertIn("Recreating", apply.call_args.kwargs["progress"])
def test_an_unknown_stack_operation_is_rejected_before_the_stack_is_touched(self):
with self.assertRaises(ValueError):
native.run(138, operation="not-a-lifecycle-operation")
if __name__ == "__main__":
unittest.main()
+4 -4
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Update, recreate or recover OCI instances
# ProxMenux - Update, modify, recreate or recover OCI instances
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
@@ -13,8 +13,8 @@
# OCI manager Apps -> Manage installed OCI applications for one
# container, without the list:
#
# VMID - the container (required for update and recreate)
# ACTION - "update", "recreate" or "recover" (required);
# VMID - the container (required for update, modify and recreate)
# ACTION - "update", "modify", "recreate" or "recover" (required);
# "recover" registers again the applications
# restored from a backup
# KEEP_BACKUP - storage where the backup taken before the
@@ -38,7 +38,7 @@ if [[ ! ${VMID:-} =~ ^[0-9]{1,9}$ ]]; then
msg_error "$(translate "Invalid VMID")"
exit 1
fi
if [[ ${ACTION:-} != "update" && ${ACTION:-} != "recreate" ]]; then
if [[ ${ACTION:-} != "update" && ${ACTION:-} != "modify" && ${ACTION:-} != "recreate" ]]; then
msg_error "$(translate "Invalid action")"
exit 1
fi