Files
ProxMenux/oci/remote/oci_remove.py
T

209 lines
8.9 KiB
Python

#!/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."""
from __future__ import annotations
import argparse
import ipaddress
import json
import os
from pathlib import Path
import re
import shutil
import socket
import subprocess
import sys
import oci_image_cache as image_cache
import oci_instances as instances
from oci_installation_state import parse_config
import oci_console
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')
def run(*args):
subprocess.run(args, check=True, capture_output=True)
def guest_config(vmid):
try:
return instances.command('pct', 'config', str(vmid))
except (subprocess.CalledProcessError, RuntimeError, OSError):
return None
def members_of(root, vmid):
"""Every container of the installation: one, or the whole stack when the
selected container belongs to one. The main container is removed last."""
record = instances.read(root, vmid)
primary_id = (record.get('stack_member') or {}).get('primary_vmid', vmid)
try:
primary = instances.read(root, primary_id)
except (OSError, ValueError, KeyError):
primary, primary_id = record, vmid
stack = primary.get('stack') or {}
members = [int(member['vmid']) for member in stack.get('members', []) if member.get('vmid')]
if primary_id not in members:
members.append(primary_id)
if vmid not in members:
members.append(vmid)
ordered = [member for member in members if member != primary_id] + [primary_id]
return primary_id, primary, ordered
def host_directories(root, members):
"""The host directories the containers were using, which are kept."""
paths = []
for vmid in members:
try:
record = instances.read(root, vmid)
except (OSError, ValueError, KeyError):
continue
for mount in record.get('deployment', {}).get('mounts', []):
if mount.get('type') == 'host-bind' and mount.get('source') not in paths:
paths.append(mount['source'])
return paths
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 or not re.fullmatch(r'vmbr[0-9]+', bridge):
return None
try:
if not ipaddress.ip_network(subnet).subnet_of(PRIVATE_STACK_NETWORK):
return None
except (TypeError, ValueError):
return None
return bridge
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'):
if int(path.stem) in removed:
continue
if re.search(rf'(?:^|[,\s])bridge={re.escape(bridge)}(?:[,\s]|$)',
path.read_text(encoding='utf-8', errors='ignore'), re.MULTILINE):
return True
for path in Path('/etc/pve/nodes').glob('*/qemu-server/*.conf'):
if re.search(rf'(?:^|[,\s])bridge={re.escape(bridge)}(?:[,\s]|$)',
path.read_text(encoding='utf-8', errors='ignore'), re.MULTILINE):
return True
return False
def release_bridge(bridge):
node = socket.gethostname().split('.', 1)[0]
subprocess.run(['ip', 'link', 'delete', bridge, 'type', 'bridge'], check=False, capture_output=True)
subprocess.run(['pvesh', 'delete', f'/nodes/{node}/network/{bridge}'], check=False, capture_output=True)
def remove_owned_host_firewall(record):
"""Remove only the narrowly scoped rule created by this installation.
A matching port alone is never evidence of ownership: administrators and
other applications may legitimately use it. Older CT-number comments are
deliberately left alone as well.
"""
plan = record.get('deployment', {}).get('host_firewall') or {}
installation_id = record.get('installation_id', '')
if not isinstance(plan, dict) or not re.fullmatch(r'[0-9a-f-]{36}', installation_id):
return
source, port = plan.get('source'), plan.get('port')
if not isinstance(source, str) or not isinstance(port, int):
return
comment = f'ProxMenux OCI firewall {installation_id}'
node = socket.gethostname().split('.', 1)[0]
try:
result = subprocess.run(['pvesh', 'get', f'/nodes/{node}/firewall/rules', '--output-format', 'json'],
check=True, capture_output=True, text=True)
rules = json.loads(result.stdout)
matches = [rule for rule in rules if rule.get('comment') == comment
and str(rule.get('dport')) == str(port)
and rule.get('source') == source
and str(rule.get('proto', '')).lower() == 'tcp'
and str(rule.get('type', '')).lower() == 'in'
and str(rule.get('action', '')).upper() == 'ACCEPT']
if len(matches) != 1 or not isinstance(matches[0].get('pos'), int):
return
subprocess.run(['pvesh', 'delete', f"/nodes/{node}/firewall/rules/{matches[0]['pos']}"],
check=True, capture_output=True)
msg_ok(f"{translate('Host firewall rule removed:')} TCP {port} {translate('from')} {source}")
except (OSError, ValueError, subprocess.CalledProcessError, json.JSONDecodeError):
# Removal already destroyed the CT. A firewall API failure must not
# turn that successful lifecycle operation into a failed one.
msg_warn(translate('The managed host firewall rule could not be removed and was left unchanged.'))
def remove(root, vmid):
primary_id, primary, members = members_of(root, vmid)
for member in members:
record = instances.read(root, member)
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'))
kept = host_directories(root, members)
bridge = private_bridge(primary)
msg_info(translate('Removing the containers...'))
for member in members:
record = instances.read(root, member)
config = guest_config(member)
if config is None:
msg_warn(f"{translate('The container no longer exists:')} CT {member}")
oci_console.remove_log(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}")
else:
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)
msg_ok(f"{translate('Container removed:')} CT {member}")
if bridge and not bridge_in_use(bridge, set(members)):
release_bridge(bridge)
msg_ok(f"{translate('Private network of the application released:')} {bridge}")
elif bridge:
msg_warn(f"{translate('The private network is still used by another container and is kept:')} {bridge}")
remove_owned_host_firewall(primary)
lifecycle = Path(f'/etc/pve/priv/proxmenux-stack-{primary_id}.json')
if lifecycle.exists() and not lifecycle.is_symlink():
lifecycle.unlink()
for member in members:
directory = instances.location(root, member).parent
if directory.is_dir() and not directory.is_symlink():
shutil.rmtree(directory)
msg_ok(translate('Saved record removed'))
for path, size in image_cache.prune(root, lock=False):
msg_ok(f"{translate('Unused image removed from the cache:')} {path.name}")
for path in kept:
msg_warn(f"{translate('Host directory listed in saved records (not targeted for removal):')} {path}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('vmid', type=int)
parser.add_argument('--root', type=Path, default=instances.ROOT)
args = parser.parse_args()
if os.geteuid() != 0:
parser.error(translate('Root privileges are required'))
try:
with instances.locked(args.root):
remove(args.root, args.vmid)
except BlockingIOError:
msg_error(translate('Another OCI operation is using the instance registry'))
return 1
except (OSError, ValueError, KeyError, RuntimeError, subprocess.CalledProcessError) as error:
msg_error(str(error) or type(error).__name__)
return 1
msg_ok(translate('The application was removed'))
return 0
if __name__ == '__main__':
raise SystemExit(main())