mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-08 22:46:41 +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.
|
||||
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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
|
||||
|
||||
@@ -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.'))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
# ==========================================================
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user