fix(oci): remove everything an OCI installation leaves, and name cluster events

This commit is contained in:
MacRimi
2026-09-28 22:31:00 +02:00
parent 4a5dd8a94e
commit 0ae601f5e7
18 changed files with 390 additions and 39 deletions
+16 -13
View File
@@ -82,7 +82,9 @@ class FencingWordingTests(unittest.TestCase):
rendered = self.render(event.event_type, event.data, lang) rendered = self.render(event.event_type, event.data, lang)
expected = catalog['runtime']['notifications']['templates']['split_brain'] expected = catalog['runtime']['notifications']['templates']['split_brain']
self.assertEqual(rendered['title'], expected['title'].format(hostname='node-a'), lang) self.assertEqual(rendered['title'], expected['title'].format(hostname='node-a'), lang)
self.assertEqual(rendered['body'], expected['body'], lang) # The body names the source event, as received, and adds no diagnosis of its own.
self.assertEqual(rendered['body'], expected['body'].replace('{reason}', event.data['reason']), lang)
self.assertIn('Check node state', rendered['body'], lang)
self.assertNotIn('split-brain', '\n'.join((rendered['title'], rendered['body'])).lower(), lang) self.assertNotIn('split-brain', '\n'.join((rendered['title'], rendered['body'])).lower(), lang)
self.assertNotIn('quorum', rendered['body'].lower(), lang) self.assertNotIn('quorum', rendered['body'].lower(), lang)
self.assertEqual(webhook(receiver, {'type': 'fencing', 'severity': 'warning', self.assertEqual(webhook(receiver, {'type': 'fencing', 'severity': 'warning',
@@ -107,7 +109,8 @@ class FencingWordingTests(unittest.TestCase):
self.assertEqual(args[0], 'split_brain') self.assertEqual(args[0], 'split_brain')
for lang, catalog in self.catalogs.items(): for lang, catalog in self.catalogs.items():
rendered = self.render(args[0], args[2], lang) rendered = self.render(args[0], args[2], lang)
self.assertEqual(rendered['body'], catalog['runtime']['notifications']['templates']['split_brain']['body'], lang) body = catalog['runtime']['notifications']['templates']['split_brain']['body']
self.assertEqual(rendered['body'], body.replace('{reason}', message), lang)
journal = Journal() journal = Journal()
method(journal, 'quorum lost', 'syslog') method(journal, 'quorum lost', 'syslog')
self.assertEqual(journal.events[0][0][0], 'node_disconnect') self.assertEqual(journal.events[0][0][0], 'node_disconnect')
@@ -126,13 +129,13 @@ class FencingWordingTests(unittest.TestCase):
def test_every_shipped_locale_renders_cluster_event_without_diagnosis(self): def test_every_shipped_locale_renders_cluster_event_without_diagnosis(self):
expected = { expected = {
'de': ('{hostname}: Clusterereignis gemeldet', 'Ein Clusterereignis wurde gemeldet. Prüfen Sie den Clusterstatus und das ursprüngliche Ereignis für weitere Informationen.', 'Clusterereignis', 'Clusterereignisse'), 'de': ('{hostname}: Clusterereignis gemeldet', 'Ein Clusterereignis wurde gemeldet:\n{reason}', 'Clusterereignis', 'Clusterereignisse'),
'es': ('{hostname}: evento del clúster notificado', 'Se ha notificado un evento del clúster. Consulta el estado del clúster y el evento original para obtener más detalles.', 'Evento del clúster', 'Eventos del clúster'), 'es': ('{hostname}: evento del clúster notificado', 'Se ha notificado un evento del clúster:\n{reason}', 'Evento del clúster', 'Eventos del clúster'),
'fr': ('{hostname}\u00a0: événement du cluster signalé', 'Un événement du cluster a été signalé. Vérifiez l’état du cluster et l’événement d’origine pour plus de détails.', 'Événement du cluster', 'Événements du cluster'), 'fr': ('{hostname}\u00a0: événement du cluster signalé', 'Un événement du cluster a été signalé\u00a0:\n{reason}', 'Événement du cluster', 'Événements du cluster'),
'it': ('{hostname}: evento del cluster segnalato', "È stato segnalato un evento del cluster. Controlla lo stato del cluster e l'evento originale per maggiori dettagli.", 'Evento del cluster', 'Eventi del cluster'), 'it': ('{hostname}: evento del cluster segnalato', 'È stato segnalato un evento del cluster:\n{reason}', 'Evento del cluster', 'Eventi del cluster'),
'pt': ('{hostname}: evento do cluster comunicado', 'Foi comunicado um evento do cluster. Verifique o estado do cluster e o evento original para obter mais detalhes.', 'Evento do cluster', 'Eventos do cluster'), 'pt': ('{hostname}: evento do cluster comunicado', 'Foi comunicado um evento do cluster:\n{reason}', 'Evento do cluster', 'Eventos do cluster'),
'sk': ('{hostname}: hlásená udalosť klastra', 'Bola hlásená udalosť klastra. Skontrolujte stav klastra a pôvodnú udalosť, kde nájdete ďalšie podrobnosti.', 'Udalosť klastra', 'Udalosti klastra'), 'sk': ('{hostname}: hlásená udalosť klastra', 'Bola hlásená udalosť klastra:\n{reason}', 'Udalosť klastra', 'Udalosti klastra'),
'sv': ('{hostname}: klusterhändelse rapporterad', 'En klusterhändelse har rapporterats. Kontrollera klusterstatus och den ursprungliga händelsen för mer information.', 'Klusterhändelse', 'Klusterhändelser'), 'sv': ('{hostname}: klusterhändelse rapporterad', 'En klusterhändelse har rapporterats:\n{reason}', 'Klusterhändelse', 'Klusterhändelser'),
} }
self.assertEqual(set(self.catalogs), {'en', *expected}) self.assertEqual(set(self.catalogs), {'en', *expected})
for lang, (title, body, label, settings) in expected.items(): for lang, (title, body, label, settings) in expected.items():
@@ -141,9 +144,9 @@ class FencingWordingTests(unittest.TestCase):
self.assertEqual((leaves['title'], leaves['body'], leaves['label'], self.assertEqual((leaves['title'], leaves['body'], leaves['label'],
self.catalogs[lang]['settings']['notifications']['eventTypes']['split_brain']), self.catalogs[lang]['settings']['notifications']['eventTypes']['split_brain']),
(title, body, label, settings)) (title, body, label, settings))
result = self.render('split_brain', {}, lang) result = self.render('split_brain', {'reason': 'fencing node node-b'}, lang)
self.assertEqual(result['title'], title.format(hostname='node-a')) self.assertEqual(result['title'], title.format(hostname='node-a'))
self.assertEqual(result['body'], body) self.assertEqual(result['body'], body.replace('{reason}', 'fencing node node-b'))
self.assertNotIn('quorum', result['body'].lower()) self.assertNotIn('quorum', result['body'].lower())
def test_fallback_and_translated_lookup_are_real_for_missing_and_synthetic_values(self): def test_fallback_and_translated_lookup_are_real_for_missing_and_synthetic_values(self):
@@ -152,8 +155,8 @@ class FencingWordingTests(unittest.TestCase):
for field in FIELDS: for field in FIELDS:
catalogs['it']['runtime']['notifications']['templates']['split_brain'].pop(field) catalogs['it']['runtime']['notifications']['templates']['split_brain'].pop(field)
_, render = notification_renderer(catalogs) _, render = notification_renderer(catalogs)
self.assertEqual(render('split_brain', {}, 'it')['body'], self.assertEqual(render('split_brain', {'reason': 'fencing node node-b'}, 'it')['body'],
'A cluster event was reported. Review cluster status and the source event for details.') 'A cluster event was reported:\nfencing node node-b')
catalogs['it']['runtime']['notifications']['templates']['split_brain']['body'] = 'Evento del cluster segnalato.' catalogs['it']['runtime']['notifications']['templates']['split_brain']['body'] = 'Evento del cluster segnalato.'
_, render = notification_renderer(catalogs) _, render = notification_renderer(catalogs)
self.assertEqual(render('split_brain', {}, 'it')['body'], 'Evento del cluster segnalato.') self.assertEqual(render('split_brain', {}, 'it')['body'], 'Evento del cluster segnalato.')
@@ -80,6 +80,9 @@ class InventoryMessages(unittest.TestCase):
'Path': Path, 'shutil': SimpleNamespace(rmtree=lambda path: None), 'Path': Path, 'shutil': SimpleNamespace(rmtree=lambda path: None),
'image_cache': SimpleNamespace(prune=lambda root, lock: []), 'image_cache': SimpleNamespace(prune=lambda root, lock: []),
'oci_console': SimpleNamespace(remove_log=lambda vmid: events.append(('log', vmid))), 'oci_console': SimpleNamespace(remove_log=lambda vmid: events.append(('log', vmid))),
're': re, 'guest_node': lambda vmid: None,
'remove_host_state': lambda vmid: events.append(('host', vmid)),
'release_shared_host_files': lambda hookscripts: None,
'translate': lambda text: text, 'translate': lambda text: text,
'msg_info': lambda text: events.append(('info', text)), 'msg_info': lambda text: events.append(('info', text)),
'msg_ok': lambda text: events.append(('ok', text)), 'msg_ok': lambda text: events.append(('ok', text)),
@@ -89,6 +92,7 @@ class InventoryMessages(unittest.TestCase):
self.assertIn(('info', POST_HOST + ' /bind/saved'), events) self.assertIn(('info', POST_HOST + ' /bind/saved'), events)
self.assertIn(('run', ('pct', 'destroy', '101', '--purge', '1', '--destroy-unreferenced-disks', '1')), events) self.assertIn(('run', ('pct', 'destroy', '101', '--purge', '1', '--destroy-unreferenced-disks', '1')), events)
self.assertIn(('log', 101), events) self.assertIn(('log', 101), events)
self.assertIn(('host', 101), events)
scope['translate'] = lambda text: 'Tradotto: ' + text if text == POST_HOST else text scope['translate'] = lambda text: 'Tradotto: ' + text if text == POST_HOST else text
events.clear() events.clear()
remove(Path('/inert'), 101) remove(Path('/inert'), 101)
@@ -51,6 +51,7 @@ class RemovalWordings(unittest.TestCase):
remover.guest_config = lambda member: None remover.guest_config = lambda member: None
remover.host_directories = lambda root, members: [] remover.host_directories = lambda root, members: []
remover.private_bridge = lambda primary: bridge remover.private_bridge = lambda primary: bridge
remover.bridge_in_use = lambda bridge, removed: False
state = ModuleType('oci_installation_state') state = ModuleType('oci_installation_state')
state.parse_config = lambda raw: {} state.parse_config = lambda raw: {}
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '', scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
@@ -117,6 +118,7 @@ class RemovalWordings(unittest.TestCase):
'shutil': SimpleNamespace(rmtree=lambda path: None), 'shutil': SimpleNamespace(rmtree=lambda path: None),
'image_cache': SimpleNamespace(prune=lambda root, lock: []), 'image_cache': SimpleNamespace(prune=lambda root, lock: []),
'oci_console': SimpleNamespace(remove_log=lambda vmid: None), 'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re,
'translate': lambda text: text, 'translate': lambda text: text,
'msg_info': lambda text: events.append(('info', text)), 'msg_info': lambda text: events.append(('info', text)),
'msg_ok': lambda text: events.append(('ok', text)), 'msg_ok': lambda text: events.append(('ok', text)),
@@ -185,6 +187,7 @@ class RemovalWordings(unittest.TestCase):
'shutil': SimpleNamespace(rmtree=lambda path: None), 'shutil': SimpleNamespace(rmtree=lambda path: None),
'image_cache': SimpleNamespace(prune=lambda root, lock: []), 'image_cache': SimpleNamespace(prune=lambda root, lock: []),
'oci_console': SimpleNamespace(remove_log=lambda vmid: None), 'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None,
'translate': lambda text: text, 'translate': lambda text: text,
'msg_info': lambda text: events.append(('info', text)), 'msg_info': lambda text: events.append(('info', text)),
'msg_ok': lambda text: events.append(('ok', text)), 'msg_ok': lambda text: events.append(('ok', text)),
@@ -262,6 +265,7 @@ class RemovalWordings(unittest.TestCase):
'shutil': SimpleNamespace(rmtree=lambda path: None), 'shutil': SimpleNamespace(rmtree=lambda path: None),
'image_cache': SimpleNamespace(prune=lambda root, lock: []), 'image_cache': SimpleNamespace(prune=lambda root, lock: []),
'oci_console': SimpleNamespace(remove_log=lambda vmid: None), 'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re,
'translate': lambda text: text, 'msg_info': lambda text: None, 'translate': lambda text: text, 'msg_info': lambda text: None,
'msg_ok': lambda text: events.append(('ok', text)), 'msg_warn': lambda text: None} 'msg_ok': lambda text: events.append(('ok', text)), 'msg_warn': lambda text: None}
extract(REMOVE, 'remove', scope)(Path('/inert'), 101) extract(REMOVE, 'remove', scope)(Path('/inert'), 101)
@@ -40,6 +40,7 @@ class SelectionSetupWording(TestCase):
ui = SimpleNamespace(message=Mock(), choose=Mock()) ui = SimpleNamespace(message=Mock(), choose=Mock())
fn = extracted('_interactive_management', os=SimpleNamespace(geteuid=lambda: 0), fn = extracted('_interactive_management', os=SimpleNamespace(geteuid=lambda: 0),
shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: [], shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: [],
_clean_orphans=lambda project: None,
translate=lambda s: s) translate=lambda s: s)
fn(Path('/fixture'), ui) fn(Path('/fixture'), ui)
ui.message.assert_called_once_with(EMPTY, 'OCI management') ui.message.assert_called_once_with(EMPTY, 'OCI management')
+1 -1
View File
@@ -6523,7 +6523,7 @@
}, },
"split_brain": { "split_brain": {
"title": "{hostname}: Clusterereignis gemeldet", "title": "{hostname}: Clusterereignis gemeldet",
"body": "Ein Clusterereignis wurde gemeldet. Prüfen Sie den Clusterstatus und das ursprüngliche Ereignis für weitere Informationen.", "body": "Ein Clusterereignis wurde gemeldet:\n{reason}",
"label": "Clusterereignis" "label": "Clusterereignis"
}, },
"node_disconnect": { "node_disconnect": {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6523,7 +6523,7 @@
}, },
"split_brain": { "split_brain": {
"title": "{hostname}: evento del clúster notificado", "title": "{hostname}: evento del clúster notificado",
"body": "Se ha notificado un evento del clúster. Consulta el estado del clúster y el evento original para obtener más detalles.", "body": "Se ha notificado un evento del clúster:\n{reason}",
"label": "Evento del clúster" "label": "Evento del clúster"
}, },
"node_disconnect": { "node_disconnect": {
+1 -1
View File
@@ -6523,7 +6523,7 @@
}, },
"split_brain": { "split_brain": {
"title": "{hostname} : événement du cluster signalé", "title": "{hostname} : événement du cluster signalé",
"body": "Un événement du cluster a été signalé. Vérifiez l’état du cluster et l’événement d’origine pour plus de détails.", "body": "Un événement du cluster a été signalé :\n{reason}",
"label": "Événement du cluster" "label": "Événement du cluster"
}, },
"node_disconnect": { "node_disconnect": {
+1 -1
View File
@@ -6523,7 +6523,7 @@
}, },
"split_brain": { "split_brain": {
"title": "{hostname}: evento del cluster segnalato", "title": "{hostname}: evento del cluster segnalato",
"body": "È stato segnalato un evento del cluster. Controlla lo stato del cluster e l'evento originale per maggiori dettagli.", "body": "È stato segnalato un evento del cluster:\n{reason}",
"label": "Evento del cluster" "label": "Evento del cluster"
}, },
"node_disconnect": { "node_disconnect": {
+1 -1
View File
@@ -6523,7 +6523,7 @@
}, },
"split_brain": { "split_brain": {
"title": "{hostname}: evento do cluster comunicado", "title": "{hostname}: evento do cluster comunicado",
"body": "Foi comunicado um evento do cluster. Verifique o estado do cluster e o evento original para obter mais detalhes.", "body": "Foi comunicado um evento do cluster:\n{reason}",
"label": "Evento do cluster" "label": "Evento do cluster"
}, },
"node_disconnect": { "node_disconnect": {
+1 -1
View File
@@ -6522,7 +6522,7 @@
}, },
"split_brain": { "split_brain": {
"title": "{hostname}: hlásená udalosť klastra", "title": "{hostname}: hlásená udalosť klastra",
"body": "Bola hlásená udalosť klastra. Skontrolujte stav klastra a pôvodnú udalosť, kde nájdete ďalšie podrobnosti.", "body": "Bola hlásená udalosť klastra:\n{reason}",
"label": "Udalosť klastra" "label": "Udalosť klastra"
}, },
"node_disconnect": { "node_disconnect": {
+1 -1
View File
@@ -6523,7 +6523,7 @@
}, },
"split_brain": { "split_brain": {
"title": "{hostname}: klusterhändelse rapporterad", "title": "{hostname}: klusterhändelse rapporterad",
"body": "En klusterhändelse har rapporterats. Kontrollera klusterstatus och den ursprungliga händelsen för mer information.", "body": "En klusterhändelse har rapporterats:\n{reason}",
"label": "Klusterhändelse" "label": "Klusterhändelse"
}, },
"node_disconnect": { "node_disconnect": {
+3 -2
View File
@@ -4367,8 +4367,9 @@ class ProxmoxHookWatcher:
# "🔵 constructor: Problema del sistema detectado" / "+1 problema # "🔵 constructor: Problema del sistema detectado" / "+1 problema
# más del sistema (Problemas adicionales: - problema_del_sistema)" # más del sistema (Problemas adicionales: - problema_del_sistema)"
# messages the operator sees on Telegram. Preserve the PVE payload # messages the operator sees on Telegram. Preserve the PVE payload
# as `reason` so both surfaces have concrete text to render. # as `reason` so both surfaces have concrete text to render. A cluster
if event_type == 'system_problem': # event (fencing) renders it too: the message is what happened.
if event_type in ('system_problem', 'split_brain'):
reason_text = (message or title or '').strip() reason_text = (message or title or '').strip()
if reason_text: if reason_text:
data['reason'] = reason_text[:500] data['reason'] = reason_text[:500]
+1 -1
View File
@@ -1218,7 +1218,7 @@ TEMPLATES = {
# ── Cluster events ── # ── Cluster events ──
'split_brain': { 'split_brain': {
'title': '{hostname}: Cluster event reported', 'title': '{hostname}: Cluster event reported',
'body': 'A cluster event was reported. Review cluster status and the source event for details.', 'body': 'A cluster event was reported:\n{reason}',
'label': 'Cluster event', 'label': 'Cluster event',
'group': 'cluster', 'group': 'cluster',
'default_enabled': True, 'default_enabled': True,
+12 -12
View File
@@ -67,7 +67,7 @@
"A legacy gasket-dkms package was found on this host, but no Coral M.2 / PCIe hardware is present.": "Se ha encontrado un paquete heredado gasket-dkms en este host, pero no hay ningún dispositivo Coral M.2/PCIe.", "A legacy gasket-dkms package was found on this host, but no Coral M.2 / PCIe hardware is present.": "Se ha encontrado un paquete heredado gasket-dkms en este host, pero no hay ningún dispositivo Coral M.2/PCIe.",
"A managed rootfs and an unprivileged container are required": "Un rootf gestionado y un contenedor no privilegiado son necesarios", "A managed rootfs and an unprivileged container are required": "Un rootf gestionado y un contenedor no privilegiado son necesarios",
"A managed volume with backup enabled is required": "Un volumen gestionado con backup habilitado es necesario", "A managed volume with backup enabled is required": "Un volumen gestionado con backup habilitado es necesario",
"A matching managed host firewall rule may also be removed.": "también se puede eliminar una regla de firewall de host administrado coincidente.", "A matching managed host firewall rule may also be removed.": "También puede eliminarse la regla del cortafuegos del host gestionada por ProxMenux que coincida.",
"A member VMID is in use by another guest or is on another node": "Un miembro VMID está en uso por otro invitado o está en otro nodo", "A member VMID is in use by another guest or is on another node": "Un miembro VMID está en uso por otro invitado o está en otro nodo",
"A member configuration changed after the stack was checked": "Una configuración de miembro cambió después de la comprobación de la pila", "A member configuration changed after the stack was checked": "Una configuración de miembro cambió después de la comprobación de la pila",
"A member configuration changed during the preparation": "Una configuración de miembro cambió durante la preparación", "A member configuration changed during the preparation": "Una configuración de miembro cambió durante la preparación",
@@ -315,7 +315,7 @@
"All images imported and configured successfully": "Todas las imágenes importadas y configuradas correctamente.", "All images imported and configured successfully": "Todas las imágenes importadas y configuradas correctamente.",
"All imports failed": "Todas las importaciones fallaron", "All imports failed": "Todas las importaciones fallaron",
"All of them are removed.": "Se eliminan todos.", "All of them are removed.": "Se eliminan todos.",
"All of them are targeted for removal.": "Todos ellos están destinados a ser eliminados.", "All of them are targeted for removal.": "Se eliminarán todos.",
"All partitions and metadata removed.": "Se eliminaron todas las particiones y metadatos.", "All partitions and metadata removed.": "Se eliminaron todas las particiones y metadatos.",
"All physical interfaces from backup are present on target": "Todas las interfaces físicas de la copia de seguridad están presentes en el objetivo", "All physical interfaces from backup are present on target": "Todas las interfaces físicas de la copia de seguridad están presentes en el objetivo",
"All stack members are updated together. Main CT:": "Todos los miembros de la pila se actualizan juntos. CT principal:", "All stack members are updated together. Main CT:": "Todos los miembros de la pila se actualizan juntos. CT principal:",
@@ -1076,7 +1076,7 @@
"Container configured (not started):": "Contenedor configurado (no iniciado):", "Container configured (not started):": "Contenedor configurado (no iniciado):",
"Container converted to privileged": "Contenedor convertido a privilegiado", "Container converted to privileged": "Contenedor convertido a privilegiado",
"Container created:": "Container created:", "Container created:": "Container created:",
"Container data targeted for deletion:": "Datos del contenedor destinados a ser eliminados:", "Container data targeted for deletion:": "Datos de los contenedores que se eliminarán:",
"Container did not become ready in time. Skipping driver installation.": "El contenedor no estuvo listo a tiempo. Saltarse la instalación del controlador.", "Container did not become ready in time. Skipping driver installation.": "El contenedor no estuvo listo a tiempo. Saltarse la instalación del controlador.",
"Container did not start in time.": "El contenedor no arrancó a tiempo.", "Container did not start in time.": "El contenedor no arrancó a tiempo.",
"Container distro": "distribución de contenedores", "Container distro": "distribución de contenedores",
@@ -1108,7 +1108,7 @@
"Container:": "Contenedor:", "Container:": "Contenedor:",
"Containers & Docker": "Contenedores y Docker", "Containers & Docker": "Contenedores y Docker",
"Containers returned to their previous state": "Los contenedores regresaron a su estado anterior", "Containers returned to their previous state": "Los contenedores regresaron a su estado anterior",
"Containers targeted for removal:": "Contenedores destinados a ser eliminados:", "Containers targeted for removal:": "Contenedores que se eliminarán:",
"Containers that are removed:": "Contenedores que se eliminan:", "Containers that are removed:": "Contenedores que se eliminan:",
"Containers that will be created (one LXC per service, on a private network):": "Contenedores que se crearán (un LXC por servicio, en una red privada):", "Containers that will be created (one LXC per service, on a private network):": "Contenedores que se crearán (un LXC por servicio, en una red privada):",
"Containers that will receive this device": "Contenedores que recibirán este dispositivo", "Containers that will receive this device": "Contenedores que recibirán este dispositivo",
@@ -1313,7 +1313,7 @@
"Could not unmount the container filesystem:": "No podía desmontar el sistema de archivos de contenedores:", "Could not unmount the container filesystem:": "No podía desmontar el sistema de archivos de contenedores:",
"Could not unmount — disk may be busy. Removing fstab entry anyway.": "No se pudo desmontar: es posible que el disco esté ocupado. Eliminando la entrada fstab de todos modos.", "Could not unmount — disk may be busy. Removing fstab entry anyway.": "No se pudo desmontar: es posible que el disco esté ocupado. Eliminando la entrada fstab de todos modos.",
"Could not update config file.": "No se pudo actualizar el archivo de configuración.", "Could not update config file.": "No se pudo actualizar el archivo de configuración.",
"Could not verify removal of the managed host firewall rule.": "no se pudo verificar la eliminación de la regla de firewall del host administrado.", "Could not verify removal of the managed host firewall rule.": "No se pudo verificar la eliminación de la regla del cortafuegos del host gestionada por ProxMenux.",
"Could not write to:": "No se pudo escribir a:", "Could not write to:": "No se pudo escribir a:",
"Crafty Controller default login": "Inicio predeterminado de Crafty Controller", "Crafty Controller default login": "Inicio predeterminado de Crafty Controller",
"Create Directory": "Crear directorio", "Create Directory": "Crear directorio",
@@ -3166,7 +3166,7 @@
"It asks for capabilities or a relaxed confinement profile.": "Pide capacidades o un perfil de confinamiento relajado.", "It asks for capabilities or a relaxed confinement profile.": "Pide capacidades o un perfil de confinamiento relajado.",
"It asks to see the processes of the host.": "Pide ver los procesos del host.", "It asks to see the processes of the host.": "Pide ver los procesos del host.",
"It cannot be removed on its own, because the application would stop working: continuing removes the whole application.": "No se puede eliminar por sí solo, porque la aplicación dejaría de funcionar: continuar elimina toda la aplicación.", "It cannot be removed on its own, because the application would stop working: continuing removes the whole application.": "No se puede eliminar por sí solo, porque la aplicación dejaría de funcionar: continuar elimina toda la aplicación.",
"It cannot be removed on its own, because the application would stop working: continuing targets the whole application for removal.": "No se puede eliminar por sí solo, porque la aplicación dejaría de funcionar: continuar apunta a toda la aplicación para su eliminación.", "It cannot be removed on its own, because the application would stop working: continuing targets the whole application for removal.": "No se puede eliminar por separado, porque la aplicación dejaría de funcionar: si continúas, se eliminará toda la aplicación.",
"It is created empty; existing data is not migrated automatically.": "Se crea vacía; los datos existentes no se migran automáticamente.", "It is created empty; existing data is not migrated automatically.": "Se crea vacía; los datos existentes no se migran automáticamente.",
"It is recommended to create a backup before continuing.": "Se recomienda crear una copia de seguridad antes de continuar.", "It is recommended to create a backup before continuing.": "Se recomienda crear una copia de seguridad antes de continuar.",
"It is strongly recommended to create a backup of your container before proceeding with the conversion.": "Se recomienda encarecidamente crear una copia de seguridad de su contenedor antes de continuar con la conversión.", "It is strongly recommended to create a backup of your container before proceeding with the conversion.": "Se recomienda encarecidamente crear una copia de seguridad de su contenedor antes de continuar con la conversión.",
@@ -4519,10 +4519,11 @@
"Privacy-first, self-hosted PDF toolkit": "kit de herramientas PDF autohospedado que prioriza la privacidad", "Privacy-first, self-hosted PDF toolkit": "kit de herramientas PDF autohospedado que prioriza la privacidad",
"Private installation record saved": "Registro de instalación privado guardado", "Private installation record saved": "Registro de instalación privado guardado",
"Private network assigned automatically:": "Red privada asignada automáticamente:", "Private network assigned automatically:": "Red privada asignada automáticamente:",
"Private network kept, because other containers still use it:": "Red privada conservada, porque otros contenedores la siguen usando:",
"Private network of the application released:": "Red privada de la aplicación liberada:", "Private network of the application released:": "Red privada de la aplicación liberada:",
"Private network of the application that is released:": "Red privada de la aplicación que se libera:", "Private network of the application that is released:": "Red privada de la aplicación que se libera:",
"Private network release attempted:": "Intento de liberación de red privada:", "Private network release attempted:": "Se ha intentado liberar la red privada:",
"Private network targeted for release if no other guest uses it:": "Red privada destinada a ser liberada si ningún otro invitado la utiliza:", "Private network targeted for release if no other guest uses it:": "Red privada que se liberará si ningún otro contenedor o VM la usa:",
"Private network:": "Red privada:", "Private network:": "Red privada:",
"Private personal knowledge management": "Gestión privada de los conocimientos personales", "Private personal knowledge management": "Gestión privada de los conocimientos personales",
"Privileged": "Privilegiado", "Privileged": "Privilegiado",
@@ -4778,7 +4779,7 @@
"Remote server via SSH (recommended — off-host, dedup across machines)": "servidor remoto a través de SSH (recomendado: fuera del host, desduplicación entre máquinas)", "Remote server via SSH (recommended — off-host, dedup across machines)": "servidor remoto a través de SSH (recomendado: fuera del host, desduplicación entre máquinas)",
"Remote verified:": "Comprobación remota:", "Remote verified:": "Comprobación remota:",
"Remounting CIFS share with open permissions...": "Remontando el recurso compartido CIFS con permisos abiertos...", "Remounting CIFS share with open permissions...": "Remontando el recurso compartido CIFS con permisos abiertos...",
"Removal command finished; review any warnings above.": "comando de eliminación finalizado;revise las advertencias anteriores.", "Removal command finished; review any warnings above.": "Eliminación terminada; revisa los avisos anteriores.",
"Remove CIFS Mount": "Quitar montaje CIFS", "Remove CIFS Mount": "Quitar montaje CIFS",
"Remove CIFS Mount (pvesm or fstab)": "Quitar montaje CIFS (pvesm o fstab)", "Remove CIFS Mount (pvesm or fstab)": "Quitar montaje CIFS (pvesm o fstab)",
"Remove CIFS Storage": "Eliminar almacenamiento CIFS", "Remove CIFS Storage": "Eliminar almacenamiento CIFS",
@@ -4824,7 +4825,7 @@
"Remove old repository files:": "Eliminar archivos antiguos del repositorio:", "Remove old repository files:": "Eliminar archivos antiguos del repositorio:",
"Remove passthrough device from VM": "Eliminar el dispositivo de paso de la VM", "Remove passthrough device from VM": "Eliminar el dispositivo de paso de la VM",
"Remove subscription banner": "Eliminar banner de suscripción", "Remove subscription banner": "Eliminar banner de suscripción",
"Remove the application? Its container disks are deleted, and only a backup can bring them back.": "¿Eliminar la aplicación? Los discos de su contenedor se eliminan y sólo una copia de seguridad puede recuperarlos.", "Remove the application? Its container disks are deleted, and only a backup can bring them back.": "¿Eliminar la aplicación? Los discos de sus contenedores se eliminan y solo un backup puede recuperarlos.",
"Remove the unprivileged flag from configuration:": "Elimine la bandera sin privilegios de la configuración:", "Remove the unprivileged flag from configuration:": "Elimine la bandera sin privilegios de la configuración:",
"Remove unused packages and their config": "Eliminar paquetes no utilizados y su configuración.", "Remove unused packages and their config": "Eliminar paquetes no utilizados y su configuración.",
"Remove: delete the application and its containers": "Eliminar: la aplicación y sus contenedores", "Remove: delete the application and its containers": "Eliminar: la aplicación y sus contenedores",
@@ -6041,7 +6042,7 @@
"The host port is already in use:": "El puerto host ya está en uso:", "The host port is already in use:": "El puerto host ya está en uso:",
"The host-monitor firewall bridge does not match the selected bridge": "El bridge del plan de cortafuegos no coincide con el bridge seleccionado", "The host-monitor firewall bridge does not match the selected bridge": "El bridge del plan de cortafuegos no coincide con el bridge seleccionado",
"The host-monitor firewall declaration is invalid": "La declaración de cortafuegos del monitor del host no es válida", "The host-monitor firewall declaration is invalid": "La declaración de cortafuegos del monitor del host no es válida",
"The host-monitor firewall port does not match exactly one TCP port in the container contract": "El puerto del firewall del monitor de host no coincide exactamente con un puerto TCP en el contrato del contenedor", "The host-monitor firewall port does not match exactly one TCP port in the container contract": "El puerto del cortafuegos del monitor del host no coincide con exactamente un puerto TCP del contrato del contenedor",
"The host-monitor firewall port is invalid": "El puerto del cortafuegos del monitor del host no es válido", "The host-monitor firewall port is invalid": "El puerto del cortafuegos del monitor del host no es válido",
"The host-monitor firewall port is not declared as the web port": "El puerto del cortafuegos no está declarado como puerto web", "The host-monitor firewall port is not declared as the web port": "El puerto del cortafuegos no está declarado como puerto web",
"The host-monitor firewall port is not declared by this profile": "Este perfil no declara el puerto del cortafuegos", "The host-monitor firewall port is not declared by this profile": "Este perfil no declara el puerto del cortafuegos",
@@ -6964,7 +6965,6 @@
"Weixin (WeChat) is an instant messaging, social media, and mobile payment app developed by Tencent.": "Weixin (WeChat) es una aplicación de mensajería instantánea, redes sociales y pago móvil desarrollada por Tencent.", "Weixin (WeChat) is an instant messaging, social media, and mobile payment app developed by Tencent.": "Weixin (WeChat) es una aplicación de mensajería instantánea, redes sociales y pago móvil desarrollada por Tencent.",
"What cannot be translated:": "Lo que no puede traducirse:", "What cannot be translated:": "Lo que no puede traducirse:",
"What do you want to do?": "¿Qué es lo que quieres hacer?", "What do you want to do?": "¿Qué es lo que quieres hacer?",
"What was left of containers that no longer exist has been removed:": "Se ha eliminado lo que quedaba de contenedores que ya no existen:",
"What would you like to do?": "¿Qué te gustaría hacer?", "What would you like to do?": "¿Qué te gustaría hacer?",
"When asked to select a disk, click Load Driver and load the VirtIO drivers.": "Cuando se le solicite seleccionar un disco, haga clic en Cargar controlador y cargue los controladores VirtIO.", "When asked to select a disk, click Load Driver and load the VirtIO drivers.": "Cuando se le solicite seleccionar un disco, haga clic en Cargar controlador y cargue los controladores VirtIO.",
"When this GPU is passed through to a VM, the Proxmox host will lose all video output on the physical monitor.": "Cuando esta GPU pasa a una VM, el host Proxmox perderá toda la salida de video en el monitor físico.", "When this GPU is passed through to a VM, the Proxmox host will lose all video output on the physical monitor.": "Cuando esta GPU pasa a una VM, el host Proxmox perderá toda la salida de video en el monitor físico.",
+148 -2
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Removes an OCI installation: its containers with the volumes they own, the """Removes an OCI installation: its containers with the volumes they own, the
private network of a multi-container application and its saved record. Host private network of a multi-container application, its saved record and what
directories are left exactly as they are.""" it left on the host for those containers. Host directories are left exactly
as they are."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
@@ -19,10 +20,17 @@ import oci_image_cache as image_cache
import oci_instances as instances import oci_instances as instances
from oci_installation_state import parse_config from oci_installation_state import parse_config
import oci_console import oci_console
import oci_runtime_settings as runtime_settings
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error
# The private networks ProxMenux creates for multi-container applications. # The private networks ProxMenux creates for multi-container applications.
PRIVATE_STACK_NETWORK = ipaddress.ip_network('10.77.0.0/16') PRIVATE_STACK_NETWORK = ipaddress.ip_network('10.77.0.0/16')
CLUSTER_NODES = Path('/etc/pve/nodes')
SNIPPETS = Path('/var/lib/vz/snippets')
# The App tab of ProxMenux Monitor keeps one file per VMID.
MONITOR_APPS = Path('/etc/proxmenux/apps')
HOST_MONITOR_INCLUDES = (Path('/etc/pve/proxmenux/host-monitor'), Path('/etc/pve/lxc/proxmenux-host-monitor'))
STACK_HOOK = 'proxmenux-stack-dependencies.sh'
def run(*args): def run(*args):
@@ -73,6 +81,15 @@ def private_bridge(primary):
network = (primary.get('stack') or {}).get('deployment', {}).get('network', {}) network = (primary.get('stack') or {}).get('deployment', {}).get('network', {})
bridge = network.get('private_bridge') bridge = network.get('private_bridge')
subnet = network.get('private_subnet') subnet = network.get('private_subnet')
if not bridge:
# An application of the Arr suite has no stack record: each one is
# independent, and its own leg is on the network the suite shares.
own = primary.get('deployment', {}).get('network', {})
bridge = own.get('bridge')
try:
subnet = str(ipaddress.ip_interface(own.get('ipv4')).network)
except (TypeError, ValueError):
return None
if not bridge or not re.fullmatch(r'vmbr[0-9]+', bridge): if not bridge or not re.fullmatch(r'vmbr[0-9]+', bridge):
return None return None
try: try:
@@ -83,6 +100,126 @@ def private_bridge(primary):
return bridge return bridge
def guest_node(vmid):
"""The cluster node that holds the container's configuration, if any.
`pct config` sees only the local node; a migrated container is elsewhere."""
for path in CLUSTER_NODES.glob(f'*/lxc/{int(vmid)}.conf'):
return path.parent.parent.name
return None
def _unlink(path):
try:
if path.is_file() and not path.is_symlink():
path.unlink()
except OSError:
pass
def remove_host_state(vmid):
"""What the installation kept on the host for a container that is gone:
its sysctl include, the Rclone mount hookscript and the views it
published, and its registration in the App tab of ProxMenux Monitor."""
for include in (runtime_settings.include_path(vmid), runtime_settings.legacy_include_path(vmid)):
_unlink(include)
hook = SNIPPETS / f'proxmenux-rclone-{int(vmid)}-fuse-hook.sh'
if hook.is_file() and not hook.is_symlink():
unit = f'proxmenux-rclone-publish-{int(vmid)}.service'
for action in ('stop', 'reset-failed'):
subprocess.run(['systemctl', action, unit], check=False, capture_output=True)
views = re.findall(r'^published(?:_ro)?=(/\S+)$', hook.read_text(errors='ignore'), re.MULTILINE)
for view in views:
# Only an empty directory that is no longer a mount point.
if os.path.isdir(view) and not os.path.ismount(view):
try:
os.rmdir(view)
except OSError:
pass
_unlink(hook)
_unlink(MONITOR_APPS / f'{int(vmid)}.json')
dismissed = MONITOR_APPS / '.oci-dismissed.json'
try:
entries = json.loads(dismissed.read_text())
except (OSError, ValueError):
return
if isinstance(entries, dict) and entries.pop(str(int(vmid)), None) is not None:
temporary = dismissed.with_name(dismissed.name + '.tmp')
temporary.write_text(json.dumps(entries, indent=2))
os.replace(temporary, dismissed)
def _guest_configs():
texts = []
for pattern in ('*/lxc/*.conf', '*/qemu-server/*.conf'):
for path in CLUSTER_NODES.glob(pattern):
texts.append(path.read_text(encoding='utf-8', errors='ignore'))
return '\n'.join(texts)
def release_shared_host_files(hookscripts):
"""Files several installations share, once no guest of the cluster uses
them: the host-monitor include and the stack dependency hookscript."""
remaining = _guest_configs()
for include in HOST_MONITOR_INCLUDES:
if include.is_file() and f'lxc.include: {include}' not in remaining:
_unlink(include)
for volume in set(hookscripts):
if STACK_HOOK not in volume or f'hookscript: {volume}' in remaining:
continue
try:
path = Path(instances.command('pvesm', 'path', volume).decode().strip())
except (subprocess.CalledProcessError, RuntimeError, OSError):
continue
if path.name == STACK_HOOK:
_unlink(path)
def _leftovers(vmid):
"""Whether anything of the container is still on the host."""
paths = [runtime_settings.include_path(vmid), runtime_settings.legacy_include_path(vmid),
SNIPPETS / f'proxmenux-rclone-{int(vmid)}-fuse-hook.sh', MONITOR_APPS / f'{int(vmid)}.json',
*oci_console.LOG_DIR.glob(f'{int(vmid)}.console.log*')]
return any(path.exists() for path in paths)
def sweep_orphans(root):
"""Leftovers of containers that exist on no node of the cluster: the
record of one deleted from the Proxmox interface, and files an earlier
removal left behind. A record with an operation left halfway is kept,
because its backup may still be needed. Returns the VMIDs cleaned."""
found = {int(d.name) for d in root.iterdir() if d.name.isdecimal()} if root.is_dir() else set()
for directory, pattern in ((runtime_settings.include_path(0).parent, r'([0-9]+)\.sysctls'),
(runtime_settings.legacy_include_path(0).parent, r'([0-9]+)\.proxmenux-sysctls'),
(oci_console.LOG_DIR, r'([0-9]+)\.console\.log.*'),
(SNIPPETS, r'proxmenux-rclone-([0-9]+)-fuse-hook\.sh')):
if directory.is_dir():
found.update(int(m.group(1)) for m in (re.fullmatch(pattern, p.name) for p in directory.iterdir()) if m)
# Only the App tab registrations of OCI installs; the other ones belong to
# ordinary containers.
for path in MONITOR_APPS.glob('*.json') if MONITOR_APPS.is_dir() else []:
try:
apps = json.loads(path.read_text()).get('apps') or []
except (OSError, ValueError, AttributeError):
continue
if path.stem.isdecimal() and any(app.get('installed_via') == 'oci_image' for app in apps):
found.add(int(path.stem))
cleaned = []
for vmid in sorted(found):
if instances.guest_exists(vmid):
continue
record = instances.has_contract(root, vmid)
if record and not instances.release_orphan(root, vmid):
continue
if not (record or _leftovers(vmid)):
continue
oci_console.remove_log(vmid)
remove_host_state(vmid)
cleaned.append(vmid)
if cleaned:
release_shared_host_files([])
return cleaned
def bridge_in_use(bridge, removed): def bridge_in_use(bridge, removed):
"""Whether a guest that is not being removed still uses the bridge.""" """Whether a guest that is not being removed still uses the bridge."""
for path in Path('/etc/pve/nodes').glob('*/lxc/*.conf'): for path in Path('/etc/pve/nodes').glob('*/lxc/*.conf'):
@@ -163,10 +300,15 @@ def remove(root, vmid):
if record.get('pending_transaction') or record.get('pending_stack_transaction'): if record.get('pending_transaction') or record.get('pending_stack_transaction'):
raise ValueError(translate('An operation of this installation has not finished; ' raise ValueError(translate('An operation of this installation has not finished; '
'recover it from the management menu before removing it')) 'recover it from the management menu before removing it'))
node = guest_node(member) if guest_config(member) is None else None
if node:
raise ValueError(f"{translate('The container runs on another node of the cluster; migrate it back to this node to remove it:')} "
f"CT {member} ({node})")
kept = host_directories(root, members) kept = host_directories(root, members)
bridge = private_bridge(primary) bridge = private_bridge(primary)
incomplete = False incomplete = False
msg_info(translate('Removing the containers...')) msg_info(translate('Removing the containers...'))
hookscripts = []
for member in members: for member in members:
record = instances.read(root, member) record = instances.read(root, member)
config = guest_config(member) config = guest_config(member)
@@ -174,13 +316,16 @@ def remove(root, vmid):
msg_warn(f"{translate('The container no longer exists:')} CT {member}") msg_warn(f"{translate('The container no longer exists:')} CT {member}")
incomplete = True incomplete = True
oci_console.remove_log(member) oci_console.remove_log(member)
remove_host_state(member)
elif instances.identity(config) != record['installation_id']: elif instances.identity(config) != record['installation_id']:
msg_warn(f"{translate('The VMID belongs to another container now and is not touched:')} CT {member}") msg_warn(f"{translate('The VMID belongs to another container now and is not touched:')} CT {member}")
incomplete = True incomplete = True
else: else:
hookscripts += re.findall(r'^hookscript: (\S+)$', config.decode(errors='ignore'), re.MULTILINE)
subprocess.run(['pct', 'stop', str(member), '--skiplock', '1'], check=False, capture_output=True) subprocess.run(['pct', 'stop', str(member), '--skiplock', '1'], check=False, capture_output=True)
run('pct', 'destroy', str(member), '--purge', '1', '--destroy-unreferenced-disks', '1') run('pct', 'destroy', str(member), '--purge', '1', '--destroy-unreferenced-disks', '1')
oci_console.remove_log(member) oci_console.remove_log(member)
remove_host_state(member)
msg_ok(f"{translate('Container removed:')} CT {member}") msg_ok(f"{translate('Container removed:')} CT {member}")
if bridge and not bridge_in_use(bridge, set(members)): if bridge and not bridge_in_use(bridge, set(members)):
released = release_bridge(bridge) released = release_bridge(bridge)
@@ -192,6 +337,7 @@ def remove(root, vmid):
msg_info(f"{translate('The private network is still used by another container and is kept:')} {bridge}") msg_info(f"{translate('The private network is still used by another container and is kept:')} {bridge}")
if not remove_owned_host_firewall(primary): if not remove_owned_host_firewall(primary):
incomplete = True incomplete = True
release_shared_host_files(hookscripts)
lifecycle = Path(f'/etc/pve/priv/proxmenux-stack-{primary_id}.json') lifecycle = Path(f'/etc/pve/priv/proxmenux-stack-{primary_id}.json')
if lifecycle.exists() and not lifecycle.is_symlink(): if lifecycle.exists() and not lifecycle.is_symlink():
lifecycle.unlink() lifecycle.unlink()
+16 -1
View File
@@ -140,10 +140,23 @@ def interactive_management(project, ui):
ui.message(translate('OCI management could not be completed. Check the backend status; no additional cleanup has been authorized.'), translate('OCI management')) ui.message(translate('OCI management could not be completed. Check the backend status; no additional cleanup has been authorized.'), translate('OCI management'))
def _clean_orphans(project):
"""Remove, silently, what is left of containers that exist on no node of the cluster."""
sys.path.insert(0, str(project / 'remote'))
import oci_instances as instances
import oci_remove
try:
with instances.locked(instances.ROOT):
oci_remove.sweep_orphans(instances.ROOT)
except (BlockingIOError, OSError, ValueError):
pass
def _interactive_management(project, ui): def _interactive_management(project, ui):
if os.geteuid() != 0 or not shutil.which('pct'): if os.geteuid() != 0 or not shutil.which('pct'):
ui.message(translate('This interface runs on the Proxmox node as root. Open OCI manager Apps from the ProxMenux menu on the Proxmox host.'), translate('OCI management')) ui.message(translate('This interface runs on the Proxmox node as root. Open OCI manager Apps from the ProxMenux menu on the Proxmox host.'), translate('OCI management'))
return return
_clean_orphans(project)
rows = saved_inventory(project) rows = saved_inventory(project)
if not rows: if not rows:
ui.message(translate('No registered OCI containers are available for selection on this host.'), translate('OCI management')) ui.message(translate('No registered OCI containers are available for selection on this host.'), translate('OCI management'))
@@ -316,8 +329,10 @@ def _removal_summary(project, vmid):
f"{translate('containers of')} {application}. {alone}", ''] f"{translate('containers of')} {application}. {alone}", '']
text += [translate('Containers targeted for removal:'), *lines, '', text += [translate('Containers targeted for removal:'), *lines, '',
translate('Container data targeted for deletion:'), *volumes] translate('Container data targeted for deletion:'), *volumes]
if bridge: if bridge and not oci_remove.bridge_in_use(bridge, set(members)):
text += ['', f"{translate('Private network targeted for release if no other guest uses it:')} {bridge}"] text += ['', f"{translate('Private network targeted for release if no other guest uses it:')} {bridge}"]
elif bridge:
text += ['', f"{translate('Private network kept, because other containers still use it:')} {bridge}"]
if (primary.get('deployment') or {}).get('host_firewall'): if (primary.get('deployment') or {}).get('host_firewall'):
text += ['', translate('A matching managed host firewall rule may also be removed.')] text += ['', translate('A matching managed host firewall rule may also be removed.')]
if kept: if kept:
+177
View File
@@ -0,0 +1,177 @@
"""Removing an OCI application leaves nothing of it on the host, and nothing another guest still uses is touched."""
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
import oci_remove
import oci_runtime_settings as runtime_settings
HOOK = """#!/usr/bin/env bash
inside=/data/mounts/drive
published={shared}/rw/drive
published_ro={shared}/ro/drive
"""
class RemovalCleanupTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.nodes = self.root / "nodes"
(self.nodes / "amd/lxc").mkdir(parents=True)
(self.nodes / "pve2/lxc").mkdir(parents=True)
self.snippets = self.root / "snippets"
self.snippets.mkdir()
self.apps = self.root / "apps"
self.apps.mkdir()
self.cluster = self.root / "proxmenux"
self.cluster.mkdir()
self.legacy = self.root / "legacy"
self.legacy.mkdir()
self.host_monitor = (self.cluster / "host-monitor", self.legacy / "proxmenux-host-monitor")
patches = [
patch.object(oci_remove, "CLUSTER_NODES", self.nodes),
patch.object(oci_remove, "SNIPPETS", self.snippets),
patch.object(oci_remove, "MONITOR_APPS", self.apps),
patch.object(oci_remove, "HOST_MONITOR_INCLUDES", self.host_monitor),
patch.object(runtime_settings, "include_path", lambda vmid: self.cluster / f"{vmid}.sysctls"),
patch.object(runtime_settings, "legacy_include_path", lambda vmid: self.legacy / f"{vmid}.proxmenux-sysctls"),
patch.object(oci_remove.subprocess, "run"),
]
for item in patches:
item.start()
self.addCleanup(item.stop)
def test_every_file_of_the_container_is_removed(self):
(self.cluster / "113.sysctls").write_text("lxc.sysctl.net.ipv4.ip_unprivileged_port_start = 0\n")
(self.legacy / "113.proxmenux-sysctls").write_text("x\n")
shared = self.root / "shared"
for view in ("rw/drive", "ro/drive"):
(shared / view).mkdir(parents=True)
(self.snippets / "proxmenux-rclone-113-fuse-hook.sh").write_text(HOOK.format(shared=shared))
(self.apps / "113.json").write_text("{}")
(self.apps / ".oci-dismissed.json").write_text(json.dumps({"113": "a", "112": "b"}))
oci_remove.remove_host_state(113)
self.assertFalse((self.cluster / "113.sysctls").exists())
self.assertFalse((self.legacy / "113.proxmenux-sysctls").exists())
self.assertFalse((self.snippets / "proxmenux-rclone-113-fuse-hook.sh").exists())
self.assertFalse((shared / "rw/drive").exists() or (shared / "ro/drive").exists())
self.assertFalse((self.apps / "113.json").exists())
self.assertEqual(json.loads((self.apps / ".oci-dismissed.json").read_text()), {"112": "b"})
def test_a_published_view_with_content_is_kept(self):
shared = self.root / "shared"
(shared / "rw/drive").mkdir(parents=True)
(shared / "rw/drive/file").write_text("data")
(self.snippets / "proxmenux-rclone-113-fuse-hook.sh").write_text(HOOK.format(shared=shared))
oci_remove.remove_host_state(113)
self.assertTrue((shared / "rw/drive/file").exists())
def test_other_containers_are_not_touched(self):
(self.cluster / "114.sysctls").write_text("x\n")
(self.apps / "114.json").write_text("{}")
oci_remove.remove_host_state(113)
self.assertTrue((self.cluster / "114.sysctls").exists() and (self.apps / "114.json").exists())
def test_shared_host_monitor_include_is_kept_while_a_guest_uses_it(self):
self.host_monitor[0].write_text("lxc.namespace.share.net = 1\n")
(self.nodes / "pve2/lxc/120.conf").write_text(f"arch: amd64\nlxc.include: {self.host_monitor[0]}\n")
oci_remove.release_shared_host_files([])
self.assertTrue(self.host_monitor[0].exists())
(self.nodes / "pve2/lxc/120.conf").unlink()
oci_remove.release_shared_host_files([])
self.assertFalse(self.host_monitor[0].exists())
def test_stack_hookscript_is_removed_only_when_no_guest_uses_it(self):
hook = self.snippets / "proxmenux-stack-dependencies.sh"
hook.write_text("#!/bin/sh\n")
volume = "local:snippets/proxmenux-stack-dependencies.sh"
with patch.object(oci_remove.instances, "command", return_value=f"{hook}\n".encode()):
(self.nodes / "amd/lxc/130.conf").write_text(f"hookscript: {volume}\n")
oci_remove.release_shared_host_files([volume])
self.assertTrue(hook.exists())
(self.nodes / "amd/lxc/130.conf").unlink()
oci_remove.release_shared_host_files([volume, "local:snippets/someone-else.sh"])
self.assertFalse(hook.exists())
def sweep(self, existing=()):
registry = self.root / "instances"
registry.mkdir(exist_ok=True)
logs = self.root / "logs"
logs.mkdir(exist_ok=True)
with patch.object(oci_remove.oci_console, "LOG_DIR", logs), \
patch.object(oci_remove.instances, "guest_exists", lambda vmid: vmid in existing), \
patch.object(oci_remove.instances, "has_contract", lambda root, vmid: (root / str(vmid) / "oci-compose.json").exists()), \
patch.object(oci_remove.instances, "release_orphan", self.retire):
return oci_remove.sweep_orphans(registry), registry, logs
def retire(self, root, vmid):
record = root / str(vmid) / "oci-compose.json"
if json.loads(record.read_text()).get("pending_transaction"):
return False
record.replace(record.with_name("retired-x.json"))
return True
def test_sweep_removes_what_is_left_of_containers_that_no_longer_exist(self):
registry = self.root / "instances"
(registry / "151").mkdir(parents=True)
(registry / "151/oci-compose.json").write_text("{}")
(self.legacy / "9901.proxmenux-sysctls").write_text("x\n")
(self.cluster / "100.sysctls").write_text("x\n")
cleaned, registry, logs = self.sweep(existing={100})
self.assertEqual(cleaned, [151, 9901])
self.assertTrue((registry / "151/retired-x.json").exists())
self.assertFalse((self.legacy / "9901.proxmenux-sysctls").exists())
# A container that exists keeps everything.
self.assertTrue((self.cluster / "100.sysctls").exists())
# Nothing left: the next visit cleans and reports nothing.
self.assertEqual(self.sweep(existing={100})[0], [])
def test_sweep_finds_oci_registrations_of_the_app_tab_only(self):
(self.apps / "113.json").write_text(json.dumps({"apps": [{"installed_via": "oci_image"}]}))
(self.apps / "114.json").write_text(json.dumps({"apps": [{"installed_via": "dpkg"}]}))
self.assertEqual(self.sweep()[0], [113])
self.assertFalse((self.apps / "113.json").exists())
self.assertTrue((self.apps / "114.json").exists())
def test_sweep_keeps_an_operation_left_halfway(self):
registry = self.root / "instances"
(registry / "152").mkdir(parents=True)
(registry / "152/oci-compose.json").write_text(json.dumps({"pending_transaction": "x"}))
(self.cluster / "152.sysctls").write_text("x\n")
self.assertEqual(self.sweep()[0], [])
self.assertTrue((self.cluster / "152.sysctls").exists())
def test_private_network_of_a_stack_and_of_an_arr_suite_application(self):
stack = {"stack": {"deployment": {"network": {"private_bridge": "vmbr10", "private_subnet": "10.77.0.0/24"}}}}
suite = {"deployment": {"network": {"bridge": "vmbr11", "ipv4": "10.77.1.31/24"}}}
lan = {"deployment": {"network": {"bridge": "vmbr0", "ipv4": "192.168.0.40/24"}}}
dhcp = {"deployment": {"network": {"bridge": "vmbr0", "ipv4": "dhcp"}}}
self.assertEqual(oci_remove.private_bridge(stack), "vmbr10")
self.assertEqual(oci_remove.private_bridge(suite), "vmbr11")
self.assertIsNone(oci_remove.private_bridge(lan))
self.assertIsNone(oci_remove.private_bridge(dhcp))
def test_the_suite_network_is_kept_while_another_application_uses_it(self):
(self.nodes / "amd/lxc/180.conf").write_text("net0: name=eth0,bridge=vmbr11,ip=10.77.1.30/24\n")
(self.nodes / "amd/lxc/181.conf").write_text("net0: name=eth0,bridge=vmbr11,ip=10.77.1.31/24\n")
with patch.object(oci_remove, "Path", lambda value: self.nodes if value == "/etc/pve/nodes" else Path(value)):
self.assertTrue(oci_remove.bridge_in_use("vmbr11", {181}))
self.assertFalse(oci_remove.bridge_in_use("vmbr11", {180, 181}))
def test_a_container_on_another_node_is_found(self):
(self.nodes / "pve2/lxc/113.conf").write_text("arch: amd64\n")
self.assertEqual(oci_remove.guest_node(113), "pve2")
self.assertIsNone(oci_remove.guest_node(114))
if __name__ == "__main__":
unittest.main()