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
+148 -2
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env python3
"""Removes an OCI installation: its containers with the volumes they own, the
private network of a multi-container application and its saved record. Host
directories are left exactly as they are."""
private network of a multi-container application, its saved record and what
it left on the host for those containers. Host directories are left exactly
as they are."""
from __future__ import annotations
import argparse
@@ -19,10 +20,17 @@ import oci_image_cache as image_cache
import oci_instances as instances
from oci_installation_state import parse_config
import oci_console
import oci_runtime_settings as runtime_settings
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error
# The private networks ProxMenux creates for multi-container applications.
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):
@@ -73,6 +81,15 @@ def private_bridge(primary):
network = (primary.get('stack') or {}).get('deployment', {}).get('network', {})
bridge = network.get('private_bridge')
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):
return None
try:
@@ -83,6 +100,126 @@ def private_bridge(primary):
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):
"""Whether a guest that is not being removed still uses the bridge."""
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'):
raise ValueError(translate('An operation of this installation has not finished; '
'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)
bridge = private_bridge(primary)
incomplete = False
msg_info(translate('Removing the containers...'))
hookscripts = []
for member in members:
record = instances.read(root, member)
config = guest_config(member)
@@ -174,13 +316,16 @@ def remove(root, vmid):
msg_warn(f"{translate('The container no longer exists:')} CT {member}")
incomplete = True
oci_console.remove_log(member)
remove_host_state(member)
elif instances.identity(config) != record['installation_id']:
msg_warn(f"{translate('The VMID belongs to another container now and is not touched:')} CT {member}")
incomplete = True
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)
run('pct', 'destroy', str(member), '--purge', '1', '--destroy-unreferenced-disks', '1')
oci_console.remove_log(member)
remove_host_state(member)
msg_ok(f"{translate('Container removed:')} CT {member}")
if bridge and not bridge_in_use(bridge, set(members)):
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}")
if not remove_owned_host_firewall(primary):
incomplete = True
release_shared_host_files(hookscripts)
lifecycle = Path(f'/etc/pve/priv/proxmenux-stack-{primary_id}.json')
if lifecycle.exists() and not lifecycle.is_symlink():
lifecycle.unlink()