mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-17 11:02:02 +00:00
386 lines
15 KiB
Python
386 lines
15 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""Copies of the record of an OCI application that outlive the host record.
|
||
|
|
|
||
|
|
The record of an installation lives on the disk of the node that installed
|
||
|
|
it. Two copies are kept with it, written together after every operation:
|
||
|
|
|
||
|
|
- inside the container, in its root filesystem, so a backup carries it: a
|
||
|
|
container restored on another Proxmox host, or on this one after a
|
||
|
|
reinstall, still has what is needed to register it again. The container
|
||
|
|
could change this copy, so it is checked against the configuration Proxmox
|
||
|
|
restored before anything is taken from it;
|
||
|
|
- in /etc/pve, which every node of a cluster shares and only root of the host
|
||
|
|
reads, so a container that migrates finds its record on the node it moves
|
||
|
|
to. This one is trusted.
|
||
|
|
|
||
|
|
Neither is read back as a source of truth while the host record is current.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import contextlib
|
||
|
|
import datetime
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
from pathlib import Path
|
||
|
|
import re
|
||
|
|
import socket
|
||
|
|
import stat
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import uuid
|
||
|
|
|
||
|
|
import oci_instances as instances
|
||
|
|
from oci_installation_state import sha
|
||
|
|
from oci_ui import translate
|
||
|
|
|
||
|
|
DIRECTORY = '.proxmenux'
|
||
|
|
NAME = 'oci-record.json'
|
||
|
|
KIND = 'proxmenux.oci-carried-record'
|
||
|
|
STAMP = 'carried.sha256'
|
||
|
|
LIMIT = 16 * 1024 * 1024
|
||
|
|
STACK_CONTRACT = '/etc/pve/priv/proxmenux-stack-{}.json'
|
||
|
|
NVIDIA_HOOK = re.compile(r'/usr/local/lib/proxmenux/oci/nvidia-mount-([a-f0-9]{64})\.sh')
|
||
|
|
SNIPPETS = Path('/var/lib/vz/snippets')
|
||
|
|
# Every node of a cluster reads this folder and only root of the host can:
|
||
|
|
# a container that moves to another node finds its record there.
|
||
|
|
CLUSTER = Path('/etc/pve/priv/proxmenux/oci')
|
||
|
|
RCLONE_HOOK = re.compile(r'^hookscript: local:snippets/(proxmenux-rclone-[0-9]+-fuse-hook\.sh)$', re.MULTILINE)
|
||
|
|
|
||
|
|
|
||
|
|
def _run(*args):
|
||
|
|
return subprocess.run(args, capture_output=True, text=True, timeout=120, check=False)
|
||
|
|
|
||
|
|
|
||
|
|
def running_pid(vmid):
|
||
|
|
result = _run('lxc-info', '-n', str(int(vmid)), '-pH')
|
||
|
|
pid = result.stdout.strip()
|
||
|
|
return int(pid) if result.returncode == 0 and pid.isdigit() else None
|
||
|
|
|
||
|
|
|
||
|
|
@contextlib.contextmanager
|
||
|
|
def container_root(vmid, mount_stopped=True):
|
||
|
|
"""The root filesystem of the container as the host sees it, or None. A
|
||
|
|
running container is reached through its init process; a stopped one is
|
||
|
|
mounted for as long as the block lasts."""
|
||
|
|
pid = running_pid(vmid)
|
||
|
|
if pid:
|
||
|
|
yield Path(f'/proc/{pid}/root')
|
||
|
|
return
|
||
|
|
if not mount_stopped or _run('pct', 'mount', str(int(vmid))).returncode != 0:
|
||
|
|
yield None
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
yield Path(f'/var/lib/lxc/{int(vmid)}/rootfs')
|
||
|
|
finally:
|
||
|
|
_run('pct', 'unmount', str(int(vmid)))
|
||
|
|
|
||
|
|
|
||
|
|
def mapped_root(config):
|
||
|
|
"""The host owner that is root inside the container."""
|
||
|
|
owner = {}
|
||
|
|
for line in config.splitlines():
|
||
|
|
fields = line.partition(': ')[2].split()
|
||
|
|
if line.startswith('lxc.idmap: ') and len(fields) == 4 and fields[1] == '0' and fields[0] in 'ug':
|
||
|
|
owner.setdefault(fields[0], int(fields[2]))
|
||
|
|
default = 100000 if re.search(r'^unprivileged: 1$', config, re.MULTILINE) else 0
|
||
|
|
return owner.get('u', default), owner.get('g', default)
|
||
|
|
|
||
|
|
|
||
|
|
def _directory(root, owner=None):
|
||
|
|
"""The private folder of the copy, opened without following a link the
|
||
|
|
container could have left in its place."""
|
||
|
|
root_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
|
||
|
|
try:
|
||
|
|
if owner is not None:
|
||
|
|
with contextlib.suppress(FileExistsError):
|
||
|
|
os.mkdir(DIRECTORY, 0o700, dir_fd=root_fd)
|
||
|
|
fd = os.open(DIRECTORY, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=root_fd)
|
||
|
|
finally:
|
||
|
|
os.close(root_fd)
|
||
|
|
if owner is not None:
|
||
|
|
os.fchown(fd, *owner)
|
||
|
|
os.fchmod(fd, 0o700)
|
||
|
|
return fd
|
||
|
|
|
||
|
|
|
||
|
|
def valid_copy(value):
|
||
|
|
return (isinstance(value, dict) and value.get('kind') == KIND and value.get('schema_version') == 1
|
||
|
|
and isinstance(value.get('record'), dict))
|
||
|
|
|
||
|
|
|
||
|
|
def read_cluster(vmid):
|
||
|
|
"""The copy the cluster keeps for a container, or None."""
|
||
|
|
path = CLUSTER / f'{int(vmid)}.json'
|
||
|
|
try:
|
||
|
|
if path.is_symlink() or path.stat().st_size > LIMIT:
|
||
|
|
return None
|
||
|
|
value = json.loads(path.read_text())
|
||
|
|
except (OSError, ValueError):
|
||
|
|
return None
|
||
|
|
return value if valid_copy(value) else None
|
||
|
|
|
||
|
|
|
||
|
|
def write_cluster(vmid, value):
|
||
|
|
"""Best effort: /etc/pve is read-only without quorum and takes files of
|
||
|
|
up to 1 MiB. The copy inside the container does not depend on it."""
|
||
|
|
try:
|
||
|
|
CLUSTER.mkdir(parents=True, exist_ok=True)
|
||
|
|
(CLUSTER / f'{int(vmid)}.json').write_text(json.dumps(value))
|
||
|
|
except OSError:
|
||
|
|
return False
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def remove_cluster(vmid):
|
||
|
|
with contextlib.suppress(OSError):
|
||
|
|
(CLUSTER / f'{int(vmid)}.json').unlink()
|
||
|
|
|
||
|
|
|
||
|
|
def keep_cluster(vmid, value, generation):
|
||
|
|
"""Leave in the cluster the same copy the container carries."""
|
||
|
|
shared = read_cluster(vmid)
|
||
|
|
if shared is not None and digest(shared) == digest(value) and shared.get('generation') == generation:
|
||
|
|
return
|
||
|
|
write_cluster(vmid, dict(value, generation=generation, saved_at=value.get('saved_at')
|
||
|
|
or datetime.datetime.now(datetime.timezone.utc).isoformat()))
|
||
|
|
|
||
|
|
|
||
|
|
def replaced_elsewhere(record, record_path, shared, node):
|
||
|
|
"""Whether another node of the cluster wrote a different record for this
|
||
|
|
container after the one of this host: the container was changed there and
|
||
|
|
came back."""
|
||
|
|
if shared is None or shared.get('node') == node or shared['record'] == record \
|
||
|
|
or shared['record'].get('installation_id') != record['installation_id'] \
|
||
|
|
or shared['record'].get('vmid') != record['vmid']:
|
||
|
|
return False
|
||
|
|
try:
|
||
|
|
saved = datetime.datetime.fromisoformat(str(shared.get('saved_at')))
|
||
|
|
return saved.timestamp() > record_path.stat().st_mtime
|
||
|
|
except (ValueError, OSError, TypeError):
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def read_copy(root):
|
||
|
|
"""The copy a container carries, or None when it has none that can be used."""
|
||
|
|
try:
|
||
|
|
directory = _directory(root)
|
||
|
|
except OSError:
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
fd = os.open(NAME, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory)
|
||
|
|
except OSError:
|
||
|
|
return None
|
||
|
|
finally:
|
||
|
|
os.close(directory)
|
||
|
|
with os.fdopen(fd, 'rb') as source:
|
||
|
|
info = os.fstat(source.fileno())
|
||
|
|
if not stat.S_ISREG(info.st_mode) or info.st_size > LIMIT:
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
value = json.loads(source.read(LIMIT))
|
||
|
|
except ValueError:
|
||
|
|
return None
|
||
|
|
return value if valid_copy(value) else None
|
||
|
|
|
||
|
|
|
||
|
|
def write_copy(root, value, owner):
|
||
|
|
directory = _directory(root, owner)
|
||
|
|
temporary = f'.{NAME}.{os.getpid()}'
|
||
|
|
try:
|
||
|
|
with contextlib.suppress(FileNotFoundError):
|
||
|
|
os.unlink(temporary, dir_fd=directory)
|
||
|
|
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory)
|
||
|
|
try:
|
||
|
|
with os.fdopen(fd, 'w') as output:
|
||
|
|
json.dump(value, output)
|
||
|
|
output.flush()
|
||
|
|
os.fchown(output.fileno(), *owner)
|
||
|
|
os.fsync(output.fileno())
|
||
|
|
os.rename(temporary, NAME, src_dir_fd=directory, dst_dir_fd=directory)
|
||
|
|
except BaseException:
|
||
|
|
with contextlib.suppress(OSError):
|
||
|
|
os.unlink(temporary, dir_fd=directory)
|
||
|
|
raise
|
||
|
|
os.fsync(directory)
|
||
|
|
finally:
|
||
|
|
os.close(directory)
|
||
|
|
|
||
|
|
|
||
|
|
def rclone_mount(config):
|
||
|
|
"""Where the hookscript of an Rclone container publishes its mount on the
|
||
|
|
host. The hookscript itself is written again from the engine."""
|
||
|
|
hook = RCLONE_HOOK.search(config)
|
||
|
|
if not hook:
|
||
|
|
return None
|
||
|
|
path = SNIPPETS / hook[1]
|
||
|
|
try:
|
||
|
|
if path.is_symlink():
|
||
|
|
return None
|
||
|
|
text = path.read_text()
|
||
|
|
except (OSError, UnicodeDecodeError):
|
||
|
|
return None
|
||
|
|
name = re.search(r'^inside=/data/mounts/(\S+)$', text, re.MULTILINE)
|
||
|
|
views = {key: re.search(rf'^{key}=(/\S+)/(\S+)$', text, re.MULTILINE) for key in ('published', 'published_ro')}
|
||
|
|
parent = re.search(r'^\s*if ! mountpoint -q (/\S+); then$', text, re.MULTILINE)
|
||
|
|
if not name or not parent or not all(views.values()) \
|
||
|
|
or any(view[2] != name[1] for view in views.values()):
|
||
|
|
return None
|
||
|
|
return {'mount_name': name[1], 'shared_mount_root': views['published'][1],
|
||
|
|
'shared_mount_read_only_root': views['published_ro'][1], 'shared_mount_root_parent': parent[1]}
|
||
|
|
|
||
|
|
|
||
|
|
def bundle(record, config):
|
||
|
|
"""What the container carries: its record and the host files a start of
|
||
|
|
the application depends on."""
|
||
|
|
vmid = record['vmid']
|
||
|
|
value = {'schema_version': 1, 'kind': KIND, 'node': socket.gethostname().split('.', 1)[0],
|
||
|
|
'vmid': vmid, 'record': record}
|
||
|
|
contract = Path(STACK_CONTRACT.format(vmid))
|
||
|
|
if contract.is_file() and not contract.is_symlink():
|
||
|
|
with contextlib.suppress(OSError, ValueError):
|
||
|
|
value['stack_contract'] = json.loads(contract.read_text())
|
||
|
|
hook = NVIDIA_HOOK.search(config)
|
||
|
|
if hook:
|
||
|
|
path = Path(hook[0])
|
||
|
|
with contextlib.suppress(OSError, UnicodeDecodeError):
|
||
|
|
if path.is_file() and not path.is_symlink() and sha(path.read_bytes()) == hook[1]:
|
||
|
|
value['nvidia_hook'] = {'sha256': hook[1], 'content': path.read_text()}
|
||
|
|
mount = rclone_mount(config)
|
||
|
|
if mount:
|
||
|
|
value['rclone_mount'] = mount
|
||
|
|
return value
|
||
|
|
|
||
|
|
|
||
|
|
def digest(value):
|
||
|
|
stable = {key: item for key, item in value.items() if key not in ('saved_at', 'generation')}
|
||
|
|
return sha(json.dumps(stable, sort_keys=True, separators=(',', ':')).encode())
|
||
|
|
|
||
|
|
|
||
|
|
def read_stamp(path):
|
||
|
|
"""What this host last wrote into the container: the digest of the copy
|
||
|
|
and the mark that tells that copy from any other."""
|
||
|
|
try:
|
||
|
|
text = path.read_text().strip()
|
||
|
|
except OSError:
|
||
|
|
return {}
|
||
|
|
try:
|
||
|
|
value = json.loads(text)
|
||
|
|
except ValueError:
|
||
|
|
return {'digest': text}
|
||
|
|
return value if isinstance(value, dict) else {}
|
||
|
|
|
||
|
|
|
||
|
|
def superseded(record, existing, stamp):
|
||
|
|
"""Whether the container carries a copy this host did not write, with a
|
||
|
|
record that differs from the one of this host. That is a container that
|
||
|
|
came back: migrated to another node and changed there, restored from a
|
||
|
|
backup made before the last operation, or rolled back to a snapshot. Its
|
||
|
|
content is the one its own copy describes."""
|
||
|
|
if not stamp.get('generation') or existing.get('generation') == stamp['generation']:
|
||
|
|
return False
|
||
|
|
carried_record = existing['record']
|
||
|
|
return (carried_record.get('installation_id') == record['installation_id']
|
||
|
|
and carried_record.get('vmid') == record['vmid'] and carried_record != record)
|
||
|
|
|
||
|
|
|
||
|
|
def carry(root, vmid, mount_stopped=True, verify=False, adopted=False):
|
||
|
|
"""Leave the current record inside its container. Returns 'carried',
|
||
|
|
'current' when the copy was already up to date, 'skipped' when the
|
||
|
|
instance is in the middle of an operation or cannot be reached, or
|
||
|
|
'stale' when the container came back with another record: the record of
|
||
|
|
this host is set aside, and the container is then recovered like any
|
||
|
|
restored one. A stopped container is opened only when its copy is known to
|
||
|
|
be old, or when `verify` asks to look anyway before an operation. A record
|
||
|
|
that was just recovered is `adopted`: it replaces the copies it came from."""
|
||
|
|
try:
|
||
|
|
record = instances.read(root, vmid)
|
||
|
|
except (OSError, ValueError, KeyError):
|
||
|
|
return 'skipped'
|
||
|
|
if record.get('status') != 'installed' or record.get('pending_transaction') \
|
||
|
|
or record.get('pending_stack_transaction'):
|
||
|
|
return 'skipped'
|
||
|
|
result = _run('pct', 'config', str(vmid))
|
||
|
|
if result.returncode != 0 or instances.identity(result.stdout.encode()) != record['installation_id']:
|
||
|
|
return 'skipped'
|
||
|
|
value = bundle(record, result.stdout)
|
||
|
|
expected = digest(value)
|
||
|
|
record_path = instances.location(root, vmid)
|
||
|
|
path = record_path.parent / STAMP
|
||
|
|
stamp = read_stamp(path)
|
||
|
|
if not adopted and replaced_elsewhere(record, record_path, read_cluster(vmid), value['node']):
|
||
|
|
record_path.replace(record_path.with_name(f"retired-{record['installation_id']}.json"))
|
||
|
|
with contextlib.suppress(OSError):
|
||
|
|
path.unlink()
|
||
|
|
return 'stale'
|
||
|
|
if not running_pid(vmid):
|
||
|
|
if stamp.get('digest') == expected and stamp.get('generation') and not verify:
|
||
|
|
keep_cluster(vmid, value, stamp['generation'])
|
||
|
|
return 'current'
|
||
|
|
if not mount_stopped:
|
||
|
|
return 'skipped'
|
||
|
|
try:
|
||
|
|
with container_root(vmid, mount_stopped) as rootfs:
|
||
|
|
if rootfs is None:
|
||
|
|
return 'skipped'
|
||
|
|
existing = read_copy(rootfs)
|
||
|
|
if existing is not None and not adopted and superseded(record, existing, stamp):
|
||
|
|
outcome = 'stale'
|
||
|
|
elif existing is not None and digest(existing) == expected and existing.get('generation') \
|
||
|
|
and existing.get('generation') == stamp.get('generation'):
|
||
|
|
outcome = 'current'
|
||
|
|
else:
|
||
|
|
value['saved_at'] = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||
|
|
value['generation'] = uuid.uuid4().hex
|
||
|
|
write_copy(rootfs, value, mapped_root(result.stdout))
|
||
|
|
stamp = {'generation': value['generation']}
|
||
|
|
outcome = 'carried'
|
||
|
|
if outcome == 'stale':
|
||
|
|
record_path.replace(record_path.with_name(f"retired-{record['installation_id']}.json"))
|
||
|
|
with contextlib.suppress(OSError):
|
||
|
|
path.unlink()
|
||
|
|
return outcome
|
||
|
|
path.write_text(json.dumps({'digest': expected, 'generation': stamp.get('generation')}) + '\n')
|
||
|
|
path.chmod(0o600)
|
||
|
|
if stamp.get('generation'):
|
||
|
|
keep_cluster(vmid, value, stamp['generation'])
|
||
|
|
except OSError:
|
||
|
|
return 'skipped'
|
||
|
|
return outcome
|
||
|
|
|
||
|
|
|
||
|
|
def registered(root):
|
||
|
|
if not root.is_dir():
|
||
|
|
return []
|
||
|
|
return sorted(int(d.name) for d in root.iterdir()
|
||
|
|
if d.name.isdecimal() and instances.has_contract(root, int(d.name)))
|
||
|
|
|
||
|
|
|
||
|
|
def sync(root, vmids=None, mount_stopped=True, verify=False):
|
||
|
|
"""Bring the copies of the given instances, or of all, up to date. Never
|
||
|
|
waits for the registry: an operation in progress carries its own copy
|
||
|
|
when it ends."""
|
||
|
|
try:
|
||
|
|
with instances.locked(root):
|
||
|
|
return {vmid: carry(root, vmid, mount_stopped, verify)
|
||
|
|
for vmid in (registered(root) if vmids is None else vmids)}
|
||
|
|
except (BlockingIOError, OSError, ValueError):
|
||
|
|
return {}
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
||
|
|
parser.add_argument('vmid', type=int, nargs='*')
|
||
|
|
parser.add_argument('--root', type=Path, default=instances.ROOT)
|
||
|
|
parser.add_argument('--running-only', action='store_true')
|
||
|
|
parser.add_argument('--verify', action='store_true')
|
||
|
|
args = parser.parse_args()
|
||
|
|
if os.geteuid() != 0:
|
||
|
|
parser.error(translate('Root privileges are required'))
|
||
|
|
print(json.dumps(sync(args.root, args.vmid or None, not args.running_only, args.verify)))
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
sys.exit(main())
|