mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-09 15:06:39 +00:00
Merge pull request #423 from Vaso73/fix/oci-stack-recreate
fix(oci): recreate coordinated stacks from saved state
This commit is contained in:
@@ -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.
|
# It cannot be updated, but it can still be changed or removed.
|
||||||
ui = self._stack({'members': [{'native_stack_intent': {'adapt': True}}]}, REPLAY)
|
ui = self._stack({'members': [{'native_stack_intent': {'adapt': True}}]}, REPLAY)
|
||||||
ui.choose.assert_called_once()
|
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):
|
def _stack(self, stack, expected):
|
||||||
record = {'stack': stack}
|
record = {'stack': stack}
|
||||||
|
|||||||
@@ -954,7 +954,7 @@ export function VirtualMachines() {
|
|||||||
// A container restored from a backup carries the mark of its installation
|
// 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.
|
// and has no record on this host until it is recovered from the menu.
|
||||||
const [ociRestored, setOciRestored] = useState(false)
|
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
|
// 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
|
// 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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className={neutralBtnCls}
|
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" />
|
<RotateCcw className="h-4 w-4 mr-1.5" />
|
||||||
{t("vmLxc.ociUpdates.recreate")}
|
{t("vmLxc.ociUpdates.recreate")}
|
||||||
|
|||||||
+13
-1
@@ -7856,5 +7856,17 @@
|
|||||||
"⚠ Disk data will NOT be erased.": "⚠ Festplattendaten werden NICHT gelöscht.",
|
"⚠ 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.",
|
"⚠ 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 /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
@@ -7864,5 +7864,17 @@
|
|||||||
"⚠ Disk data will NOT be erased.": "⚠ Los datos del disco NO se borrarán.",
|
"⚠ 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.",
|
"⚠ 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 /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
@@ -7855,5 +7855,17 @@
|
|||||||
"⚠ Disk data will NOT be erased.": "⚠ Les données du disque ne seront PAS effacées.",
|
"⚠ 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.",
|
"⚠ 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 /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
@@ -7856,5 +7856,22 @@
|
|||||||
"⚠ Disk data will NOT be erased.": "⚠ I dati del disco NON verranno cancellati.",
|
"⚠ 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.",
|
"⚠ 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 /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
@@ -7856,5 +7856,17 @@
|
|||||||
"⚠ Disk data will NOT be erased.": "⚠ Os dados do disco NÃO serão apagados.",
|
"⚠ 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.",
|
"⚠ 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 /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
@@ -7855,5 +7855,17 @@
|
|||||||
"⚠ Disk data will NOT be erased.": "⚠ Dáta na disku sa NEVYMAŽÚ.",
|
"⚠ 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.",
|
"⚠ 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 /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
@@ -7856,5 +7856,17 @@
|
|||||||
"⚠ Disk data will NOT be erased.": "⚠ Diskdata kommer INTE att raderas.",
|
"⚠ 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.",
|
"⚠ 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 /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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ def resolve_candidate(reference, architecture):
|
|||||||
if '@' in reference:
|
if '@' in reference:
|
||||||
transport = repo + '@' + reference.split('@', 1)[1]
|
transport = repo + '@' + reference.split('@', 1)[1]
|
||||||
raw = command('skopeo', 'inspect', '--raw', 'docker://' + transport)
|
raw = command('skopeo', 'inspect', '--raw', 'docker://' + transport)
|
||||||
|
registry_digest = 'sha256:' + sha(raw)
|
||||||
manifest = json.loads(raw)
|
manifest = json.loads(raw)
|
||||||
if 'manifests' in manifest:
|
if 'manifests' in manifest:
|
||||||
matches = [m for m in manifest['manifests'] if m.get('platform', {}).get('architecture') == architecture
|
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
|
# 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
|
# what changes when an image is rebuilt, whether or not the application
|
||||||
# version inside it moved.
|
# 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')}
|
'defaults': defaults, 'version': version, 'created': config.get('created')}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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'))
|
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."""
|
"""Translate only the known three-member stack and retain rollback contracts."""
|
||||||
intent = primary.get('native_stack_intent', {})
|
intent = primary.get('native_stack_intent', {})
|
||||||
if intent.get('adapter', {}).get('name') != 'install_nextcloud_stack.sh':
|
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 = copy.deepcopy(translated[service['vmid']])
|
||||||
snapshot.pop('stack', None)
|
snapshot.pop('stack', None)
|
||||||
parent['stack']['members'].append(snapshot)
|
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['original_members'] = copy.deepcopy(list(records.values()))
|
||||||
plan['nextcloud_replay'] = True
|
plan['nextcloud_replay'] = True
|
||||||
return plan
|
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."""
|
"""Prepare the known Paperless stack without publishing translated recipes."""
|
||||||
if primary.get('native_stack_intent', {}).get('adapter', {}).get('name') != 'install_paperless_stack.sh':
|
if primary.get('native_stack_intent', {}).get('adapter', {}).get('name') != 'install_paperless_stack.sh':
|
||||||
raise ValueError(f"{translate('Unrecognized stack adapter:')} Paperless")
|
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 = copy.deepcopy(translated[service['vmid']])
|
||||||
snapshot.pop('stack', None)
|
snapshot.pop('stack', None)
|
||||||
parent['stack']['members'].append(snapshot)
|
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['original_members'] = copy.deepcopy(list(records.values()))
|
||||||
plan['paperless_replay'] = True
|
plan['paperless_replay'] = True
|
||||||
return plan
|
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."""
|
"""Prepare exactly the application and PostgreSQL without publishing state."""
|
||||||
if primary.get('native_stack_intent', {}).get('adapter', {}).get('name') != 'install_tandoor_stack.sh':
|
if primary.get('native_stack_intent', {}).get('adapter', {}).get('name') != 'install_tandoor_stack.sh':
|
||||||
raise ValueError(f"{translate('Unrecognized stack adapter:')} Tandoor")
|
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 = copy.deepcopy(translated[service['vmid']])
|
||||||
snapshot.pop('stack', None)
|
snapshot.pop('stack', None)
|
||||||
parent['stack']['members'].append(snapshot)
|
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['original_members'] = copy.deepcopy(list(records.values()))
|
||||||
plan['tandoor_replay'] = True
|
plan['tandoor_replay'] = True
|
||||||
return plan
|
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':
|
if primary.get('native_stack_intent', {}).get('adapter', {}).get('name') != 'install_immich_stack.sh':
|
||||||
raise ValueError(f"{translate('Unrecognized stack adapter:')} Immich")
|
raise ValueError(f"{translate('Unrecognized stack adapter:')} Immich")
|
||||||
translated = {vmid: replay.immich_record(record) for vmid, record in records.items()}
|
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 = copy.deepcopy(translated[service['vmid']])
|
||||||
snapshot.pop('stack', None)
|
snapshot.pop('stack', None)
|
||||||
parent['stack']['members'].append(snapshot)
|
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['original_members'] = copy.deepcopy(list(records.values()))
|
||||||
plan['immich_replay'] = True
|
plan['immich_replay'] = True
|
||||||
return plan
|
return plan
|
||||||
@@ -262,6 +262,10 @@ class NativeAdapter:
|
|||||||
current['pending_stack_transaction'] = str(self.journal)
|
current['pending_stack_transaction'] = str(self.journal)
|
||||||
instances.write(instances.location(self.root, vmid), current)
|
instances.write(instances.location(self.root, vmid), current)
|
||||||
config = instances.command('pct', 'config', str(record['vmid']))
|
config = instances.command('pct', 'config', str(record['vmid']))
|
||||||
|
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)
|
archive, digest = resolve_archive(record, config)
|
||||||
image = image_from_archive(str(archive))
|
image = image_from_archive(str(archive))
|
||||||
old = record['observed']['image']
|
old = record['observed']['image']
|
||||||
@@ -279,7 +283,13 @@ class NativeAdapter:
|
|||||||
msg_info(f"{translate('Checking the new image without starting it:')} {self.describe(record['vmid'])}")
|
msg_info(f"{translate('Checking the new image without starting it:')} {self.describe(record['vmid'])}")
|
||||||
self.probe_nextcloud_image(record, archive, image)
|
self.probe_nextcloud_image(record, archive, image)
|
||||||
msg_ok(f"{translate('New image compatible:')} {self.describe(record['vmid'])}")
|
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):
|
def probe_nextcloud_image(self, record, archive, image):
|
||||||
"""Import but never start a disposable rootfs before stopping the stack."""
|
"""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])
|
context.update(tandoor_replay=True, effective_record=self.records[vmid])
|
||||||
if self.plan.get('immich_replay'):
|
if self.plan.get('immich_replay'):
|
||||||
context.update(immich_replay=True, effective_record=self.records[vmid])
|
context.update(immich_replay=True, effective_record=self.records[vmid])
|
||||||
member_tx.apply(self.root, vmid, Path(prepared['archive']), 'update',
|
operation = self.plan['operation']
|
||||||
registry_digest=prepared['digest'], acknowledge_external_data=self.acknowledge,
|
member_tx.apply(self.root, vmid, Path(prepared['archive']), operation,
|
||||||
coordinated=context, progress=f"{translate('Updating')} {self.describe(vmid)}:")
|
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):
|
def start(self, vmid):
|
||||||
self.validate(self.plan)
|
self.validate(self.plan)
|
||||||
@@ -617,10 +630,13 @@ class NativeAdapter:
|
|||||||
_current = {'journal': None, 'primary': None}
|
_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
|
root = instances.ROOT
|
||||||
msg_info(translate('Checking the interrupted stack operation...') if recover
|
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):
|
with instances.locked(root):
|
||||||
selected = instances.read(root, vmid)
|
selected = instances.read(root, vmid)
|
||||||
primary_id = selected.get('stack_member', {}).get('primary_vmid', 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:
|
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077:
|
||||||
raise ValueError(translate('Unsafe dependency contract'))
|
raise ValueError(translate('Unsafe dependency contract'))
|
||||||
builder = builders[adapter_name]
|
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:
|
else:
|
||||||
plan = oci_stack_plan.build(primary, records, inventory, 'update')
|
plan = oci_stack_plan.build(primary, records, inventory, operation)
|
||||||
stack_tx.validate_plan(plan)
|
stack_tx.validate_plan(plan)
|
||||||
directory = instances.location(root, primary_id).parent / 'stack-transactions' / uuid.uuid4().hex
|
directory = instances.location(root, primary_id).parent / 'stack-transactions' / uuid.uuid4().hex
|
||||||
private_directory(directory)
|
private_directory(directory)
|
||||||
@@ -689,10 +705,11 @@ def run(vmid, recover=False, acknowledge_external_data=False, keep_backup=None):
|
|||||||
adapter.keep_backup = keep_backup
|
adapter.keep_backup = keep_backup
|
||||||
import oci_operation_notice
|
import oci_operation_notice
|
||||||
import oci_update_current
|
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):
|
oci_update_current.application_name(primary, primary_id), primary_id):
|
||||||
result = stack_tx.execute(journal, adapter, plan)
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -728,13 +745,14 @@ def main():
|
|||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument('vmid', type=int)
|
parser.add_argument('vmid', type=int)
|
||||||
parser.add_argument('--recover', action='store_true')
|
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('--acknowledge-external-data', action='store_true')
|
||||||
parser.add_argument('--keep-backup', metavar='STORAGE')
|
parser.add_argument('--keep-backup', metavar='STORAGE')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if os.geteuid() != 0:
|
if os.geteuid() != 0:
|
||||||
parser.error(translate('Root privileges on the Proxmox node are required'))
|
parser.error(translate('Root privileges on the Proxmox node are required'))
|
||||||
try:
|
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
|
return 0
|
||||||
except BlockingIOError:
|
except BlockingIOError:
|
||||||
msg_error(translate('Another OCI operation is using the registry. This operation was not started.'))
|
msg_error(translate('Another OCI operation is using the registry. This operation was not started.'))
|
||||||
|
|||||||
@@ -50,20 +50,28 @@ def run_quiet(args, error, capture=False):
|
|||||||
process.wait()
|
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.
|
# Shared by individual and coordinated operations; no guest mutation here.
|
||||||
# When the registry still serves the current digest nothing is downloaded.
|
# When the registry still serves the current digest nothing is downloaded.
|
||||||
reference = desired['template']['container_contract']['image']['reference']
|
reference = desired['template']['container_contract']['image']['reference']
|
||||||
architecture = transaction.parse_config(config)['arch']
|
architecture = transaction.parse_config(config)['arch']
|
||||||
msg_info(translate('Checking the image in the registry...'))
|
msg_info(translate('Checking the image in the registry...'))
|
||||||
transaction.log(f'image: {reference} ({architecture})')
|
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; '
|
code = ('import json,sys; from oci_installation_state import resolve_candidate; '
|
||||||
'print(json.dumps(resolve_candidate(sys.argv[1],sys.argv[2])))')
|
'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))
|
translate('Could not query the image registry'), capture=True))
|
||||||
digest = candidate['manifest_digest']
|
digest = candidate['manifest_digest']
|
||||||
if not re.fullmatch(r'sha256:[a-f0-9]{64}', digest):
|
if not re.fullmatch(r'sha256:[a-f0-9]{64}', digest):
|
||||||
raise ValueError(translate('Invalid registry 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]})")
|
msg_ok(f"{translate('Image:')} {reference} ({candidate.get('version') or digest[7:19]})")
|
||||||
if current and same_image(candidate, current):
|
if current and same_image(candidate, current):
|
||||||
return None, digest
|
return None, digest
|
||||||
|
|||||||
@@ -595,9 +595,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
rclone_parser.add_argument("--host", default="auto")
|
rclone_parser.add_argument("--host", default="auto")
|
||||||
rclone_parser.add_argument("--dry-run", action="store_true")
|
rclone_parser.add_argument("--dry-run", action="store_true")
|
||||||
subparsers.add_parser("recover", help="Register again the OCI applications restored from a backup")
|
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("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",
|
manage_parser.add_argument("--keep-backup", metavar="STORAGE",
|
||||||
help="Keep the backup taken before the update in this Proxmox storage")
|
help="Keep the backup taken before the update in this Proxmox storage")
|
||||||
manage_parser.add_argument("--unattended", action="store_true",
|
manage_parser.add_argument("--unattended", action="store_true",
|
||||||
|
|||||||
@@ -285,6 +285,8 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()):
|
|||||||
return False
|
return False
|
||||||
if row['stack']:
|
if row['stack']:
|
||||||
return _manage_stack(project, ui, row, action, lifecycle_args)
|
return _manage_stack(project, ui, row, action, lifecycle_args)
|
||||||
|
if action == 'modify':
|
||||||
|
return False
|
||||||
if not row['pending']:
|
if not row['pending']:
|
||||||
if row['status'] != 'installed' or row['reason'] != 'matched':
|
if row['status'] != 'installed' or row['reason'] != 'matched':
|
||||||
ui.message(translate('The instance identity or status must be reviewed before updating.'), translate('OCI management'))
|
ui.message(translate('The instance identity or status must be reviewed before updating.'), translate('OCI management'))
|
||||||
@@ -483,18 +485,26 @@ def _manage_stack(project, ui, row, action=None, lifecycle_args=()):
|
|||||||
if action is None:
|
if action is None:
|
||||||
# A stack that cannot be updated can still be removed.
|
# A stack that cannot be updated can still be removed.
|
||||||
options = [('update', translate('Update every container of the application'))] if updatable else []
|
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')))
|
options.append(('remove', translate('Remove: delete the application and its containers')))
|
||||||
action = ui.choose(translate('Manage OCI stack'), options, options[0][0])
|
action = ui.choose(translate('Manage OCI stack'), options, options[0][0])
|
||||||
if action is None:
|
if action is None:
|
||||||
return False
|
return False
|
||||||
if action == 'remove':
|
if action == 'remove':
|
||||||
return _remove(project, ui, primary_id)
|
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':
|
if action == 'recreate':
|
||||||
# Nothing is rebuilt: only the extra paths and devices of the
|
if not ui.review(translate('All {count} containers of the application will be recreated from their saved '
|
||||||
# application container change.
|
'image digests (main CT: {vmid}). The stack is stopped, every container is '
|
||||||
from .stack_recreation import recreate_stack
|
'backed up and replaced, then checked. If anything fails, the backups are restored.')
|
||||||
return recreate_stack(project, ui, primary, _run_lifecycle)
|
.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 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 '
|
'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 '
|
'is stopped, each container is backed up and replaced with its new image. If anything '
|
||||||
@@ -512,9 +522,13 @@ def _manage_stack(project, ui, row, action=None, lifecycle_args=()):
|
|||||||
command.append('--recover')
|
command.append('--recover')
|
||||||
else:
|
else:
|
||||||
command.extend(lifecycle_args)
|
command.extend(lifecycle_args)
|
||||||
|
if action == 'recreate':
|
||||||
|
command.extend(['--operation', 'recreate'])
|
||||||
if '--acknowledge-external-data' not in command:
|
if '--acknowledge-external-data' not in command:
|
||||||
command.append('--acknowledge-external-data')
|
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):
|
if completed and not pending and not getattr(ui, 'unattended', False):
|
||||||
images.offer_removal(ui, [int(member['vmid']) for member in members])
|
images.offer_removal(ui, [int(member['vmid']) for member in members])
|
||||||
return completed
|
return completed
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Recreate for a multi-container application: add or remove the extra paths
|
"""Modify the extra paths and devices of a multi-container application.
|
||||||
and devices of its application container. Its own data, its database and the
|
|
||||||
other containers are never part of it."""
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -206,7 +207,7 @@ def change_recognition(project, ui, primary, member, run_lifecycle):
|
|||||||
return run_lifecycle(command, translate('Recreate OCI'))
|
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)
|
learning = immich_learning(primary)
|
||||||
if learning is not None:
|
if learning is not None:
|
||||||
what = ui.choose(translate('What to recreate'),
|
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)
|
member = next(m for m in members if str(m['vmid']) == selected)
|
||||||
changes = plan_changes(ui, member)
|
changes = plan_changes(ui, member)
|
||||||
if changes is None:
|
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
|
return False
|
||||||
if not ui.review(summary(member, changes), translate('Recreate OCI'),
|
if not ui.review(summary(member, changes), translate('Modify OCI stack'),
|
||||||
question=translate('Recreate with these options?'), default=True):
|
question=translate('Apply these changes?'), default=True):
|
||||||
return False
|
return False
|
||||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json') as file:
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.json') as file:
|
||||||
json.dump(changes, file)
|
json.dump(changes, file)
|
||||||
file.flush()
|
file.flush()
|
||||||
return run_lifecycle([sys.executable, str(project / 'remote/oci_stack_modify.py'),
|
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()
|
||||||
@@ -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()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
# ProxMenux - Update, recreate or recover OCI instances
|
# ProxMenux - Update, modify, recreate or recover OCI instances
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
# Author : MacRimi
|
# Author : MacRimi
|
||||||
# Copyright : (c) 2024 MacRimi
|
# Copyright : (c) 2024 MacRimi
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
# OCI manager Apps -> Manage installed OCI applications for one
|
# OCI manager Apps -> Manage installed OCI applications for one
|
||||||
# container, without the list:
|
# container, without the list:
|
||||||
#
|
#
|
||||||
# VMID - the container (required for update and recreate)
|
# VMID - the container (required for update, modify and recreate)
|
||||||
# ACTION - "update", "recreate" or "recover" (required);
|
# ACTION - "update", "modify", "recreate" or "recover" (required);
|
||||||
# "recover" registers again the applications
|
# "recover" registers again the applications
|
||||||
# restored from a backup
|
# restored from a backup
|
||||||
# KEEP_BACKUP - storage where the backup taken before the
|
# 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")"
|
msg_error "$(translate "Invalid VMID")"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [[ ${ACTION:-} != "update" && ${ACTION:-} != "recreate" ]]; then
|
if [[ ${ACTION:-} != "update" && ${ACTION:-} != "modify" && ${ACTION:-} != "recreate" ]]; then
|
||||||
msg_error "$(translate "Invalid action")"
|
msg_error "$(translate "Invalid action")"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user