feat(oci): update and modify every application, and keep its address

- The backup taken before an update or a modification is made on another
  storage when it does not fit on the system disk; an operation without
  room says so before anything is stopped.
- Privileged containers, relaxed AppArmor or seccomp, process limits, the
  host monitors Glances and Netdata and single-file host mounts are
  rebuilt and recovered like any other application.
- HAOS One installs the docker-haos image: port 8123, host time zone,
  unprivileged, watchdog proposed by default; it is updated and modified
  with Home Assistant's own check on the rebuilt container.
- A container that reboots itself gets its DHCP client back.
- The installer takes the address of the container's own interface, and
  the notes of a suite or stack container show its LAN address.
- Seerr installs: its volume belongs to the image user and
  no-new-privileges goes through the include of the container.
- Removing an application deletes its seccomp profile.
This commit is contained in:
MacRimi
2026-10-08 17:14:36 +02:00
parent b629938cf1
commit f5ad4072b5
31 changed files with 1784 additions and 390 deletions
+4 -1
View File
@@ -27,6 +27,9 @@ def note(text):
"""Detail for the run log; without one, for stderr."""
try:
if OCI_LOG:
if not sys.stderr.isatty():
# The progress line goes to a file, which may be this log: end it first.
print(file=sys.stderr, flush=True)
log(OCI_LOG, text)
return
except OSError:
@@ -110,7 +113,7 @@ def probe(vmid, ip, remaining):
raise Pending(translate('Supervisor does not confirm healthy and supported yet'))
if not http_ready(f'http://{ip}:4357/', max(0.1, min(5, remaining()))):
raise Pending(translate('Observer is not responding on port 4357'))
for port in (80, 8123):
for port in (8123, 80):
if http_ready(f'http://{ip}:{port}/', max(0.1, min(5, remaining()))):
return [{'label': 'Home Assistant', 'url': f'http://{ip}:{port}/'},
{'label': 'Home Assistant Observer', 'url': f'http://{ip}:4357/'}]
+20
View File
@@ -103,6 +103,24 @@ def persist_instances(deployment, services, primary_id=None):
oci_instances.publish_stack(oci_instances.ROOT, None, template, deployment, services)
def refresh_notes(services):
"""The notes of each container were written when it was created, before
it had its leg on the local network: write them again with the address
the application is reached at."""
from oci_description import render
from oci_native_stack import access_address as reachable_address
for service in services:
vmid = int(service['vmid'])
try:
record = oci_instances.read(oci_instances.ROOT, vmid)
running = 'running' in run('pct', 'status', vmid)
description = render(record['template'], '', record['installation_id'],
reachable_address(vmid, wait=20 if running else 0))
run('pct', 'set', vmid, '--description', description)
except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error:
log(LOG, f'Notes of CT {vmid} not refreshed: {error}')
def run(*args, capture=True, timeout=180):
argv = list(map(str,args))
if capture:
@@ -811,6 +829,7 @@ def main():
fcntl.flock(lock,fcntl.LOCK_UN)
result = finish_independent_suite(services,subnet)
persist_instances(deployment, services)
refresh_notes(services)
result.update(completion_notes=list(deployment.get('completion_notes',[])), log=LOG)
print('PROXMENUX_RESULT='+base64.b64encode(json.dumps(result).encode()).decode(),flush=True)
return
@@ -852,6 +871,7 @@ def main():
msg_ok(translate('LAN address applied to the application URLs'))
result=results[primary['name']]
persist_instances(deployment, services, primary_id)
refresh_notes(services)
# The credentials of the main service come from its own installation.
result.update(vmid=primary_id,ip=lan,stack_vmids={s['name']:s['vmid'] for s in services},
urls=[{'label':'Web UI','url':public_url}],
+28 -12
View File
@@ -745,6 +745,21 @@ apply_post_start_configuration() {
esac
}
# The first address of the container on an interface Proxmox gave it. A
# container that runs Docker inside also lists the ones of its inner bridges.
# Fails when the interfaces cannot be read, so the caller takes what is listed.
own_ipv4() {
local name listed found own="" looked=0
listed=$(lxc-info -n "$VMID" -iH 2>/dev/null | grep -E '^[0-9]+\.') || return 1
for name in $(pct config "$VMID" 2>/dev/null | sed -n 's/^net[0-9]*: .*name=\([A-Za-z0-9_.-]*\).*/\1/p'); do
found=$(lxc-attach -n "$VMID" -s NETWORK -- ip -4 -o addr show dev "$name" scope global 2>/dev/null) || continue
looked=1
own+=$(sed -n 's/.* inet \([0-9.]*\)\/.*/\1/p' <<<"$found")$'\n'
done
(( looked == 1 )) || return 1
grep -m1 -Fx -f <(sed '/^$/d' <<<"$own") <<<"$listed" || true
}
# $1: optional result label shown before the address.
detect_container_ipv4() {
local attempt addresses label=${1:-}
@@ -755,10 +770,12 @@ detect_container_ipv4() {
fi
IP=""
for attempt in $(seq 1 15); do
IP=$(lxc-info -n "$VMID" -iH 2>/dev/null | grep -m1 -E '^[0-9]+\.' || true)
if [[ -z $IP ]]; then
addresses=$(pct exec "$VMID" -- hostname -I 2>/dev/null || true)
IP=$(tr ' ' '\n' <<<"$addresses" | grep -m1 -E '^[0-9]+\.' || true)
if ! IP=$(own_ipv4); then
IP=$(lxc-info -n "$VMID" -iH 2>/dev/null | grep -m1 -E '^[0-9]+\.' || true)
if [[ -z $IP ]]; then
addresses=$(pct exec "$VMID" -- hostname -I 2>/dev/null || true)
IP=$(tr ' ' '\n' <<<"$addresses" | grep -m1 -E '^[0-9]+\.' || true)
fi
fi
[[ -n $IP ]] && break
msg_progress "$(translate "Waiting for the network address...") $((attempt * 2))/30 s"
@@ -1705,7 +1722,8 @@ else
fi
SYSCTL_COUNT=$(jq '.security.sysctls? // [] | length' "$DEPLOYMENT_FILE")
if (( SYSCTL_COUNT > 0 )); then
NO_NEW_PRIVILEGES=$(jq -r '.security.options.no_new_privileges? // false' "$DEPLOYMENT_FILE")
if (( SYSCTL_COUNT > 0 )) || [[ $NO_NEW_PRIVILEGES == true ]]; then
SYSCTL_INCLUDE="/etc/pve/proxmenux/${VMID}.sysctls"
mkdir -p /etc/pve/proxmenux || die "$(translate "Could not create the ProxMenux folder in /etc/pve")"
SYSCTL_TEMP=$(mktemp)
@@ -1718,12 +1736,14 @@ if (( SYSCTL_COUNT > 0 )); then
printf 'lxc.sysctl.%s = %s\n' "$SYSCTL_NAME" "$SYSCTL_VALUE" >>"$SYSCTL_TEMP"
oci_log "Network sysctl prepared: ${SYSCTL_NAME}=${SYSCTL_VALUE}"
done < <(jq -r '.security.sysctls[]? | [.name,.value] | @tsv' "$DEPLOYMENT_FILE")
# Proxmox does not take this setting in the configuration of a container.
[[ $NO_NEW_PRIVILEGES != true ]] || printf 'lxc.no_new_privs = 1\n' >>"$SYSCTL_TEMP"
[[ ! -L $SYSCTL_INCLUDE ]] || die "$(translate "The sysctl include is a link:") $SYSCTL_INCLUDE"
# pmxcfs assigns its own permissions and rejects chmod.
cat "$SYSCTL_TEMP" >"$SYSCTL_INCLUDE"
rm -f "$SYSCTL_TEMP"
set_lxc_directive "lxc.include" "$SYSCTL_INCLUDE"
msg_ok "$(translate "Network sysctls prepared:") $SYSCTL_COUNT"
(( SYSCTL_COUNT == 0 )) || msg_ok "$(translate "Network sysctls prepared:") $SYSCTL_COUNT"
fi
REQUIRED_CAPABILITIES=$(jq -r '.security.required_capabilities? // [] | join(",")' "$DEPLOYMENT_FILE")
@@ -1731,13 +1751,9 @@ if [[ -n $REQUIRED_CAPABILITIES ]]; then
msg_ok "$(translate "Compose capabilities validated in the LXC user namespace:") $REQUIRED_CAPABILITIES"
fi
NO_NEW_PRIVILEGES=$(jq -r '.security.options.no_new_privileges? // false' "$DEPLOYMENT_FILE")
APPARMOR_PROFILE=$(jq -r '.security.options.apparmor_profile? // empty' "$DEPLOYMENT_FILE")
SECCOMP_PROFILE=$(jq -r '.security.options.seccomp_profile? // empty' "$DEPLOYMENT_FILE")
SELINUX_LABEL_DISABLED=$(jq -r '.security.options.selinux_label_disabled? // false' "$DEPLOYMENT_FILE")
if [[ $NO_NEW_PRIVILEGES == true ]]; then
set_lxc_directive "lxc.no_new_privs" "1"
fi
if [[ $(jq -r '.security.options.drop_all_capabilities? // false' "$DEPLOYMENT_FILE") == true ]]; then
[[ -z $REQUIRED_CAPABILITIES ]] || die "$(translate "Capabilities cannot be kept and all dropped at the same time")"
set_lxc_directive "lxc.cap.drop" ""
@@ -2057,9 +2073,9 @@ if [[ $START_AFTER == 1 && $HAS_STARTUP_HEALTHCHECK == 1 ]]; then
HC_PORT=$(jq -er '.port' <<<"$STARTUP_HEALTHCHECK")
HC_PATH=$(jq -er '.path' <<<"$STARTUP_HEALTHCHECK")
HC_TIMEOUT=$(jq -er '.timeout_seconds' <<<"$STARTUP_HEALTHCHECK")
HC_REQUEST_TIMEOUT=$(jq -er '.request_timeout_seconds' <<<"$STARTUP_HEALTHCHECK")
HC_REQUEST_TIMEOUT=$(jq -r '.request_timeout_seconds // 5' <<<"$STARTUP_HEALTHCHECK")
HC_STABILITY=$(jq -r '.stability_seconds // 0' <<<"$STARTUP_HEALTHCHECK")
HC_VERIFY_TLS=$(jq -r '.verify_tls' <<<"$STARTUP_HEALTHCHECK")
HC_VERIFY_TLS=$(jq -r '.verify_tls // false' <<<"$STARTUP_HEALTHCHECK")
[[ $HC_SCHEME == http || $HC_SCHEME == https ]] || die "$(translate "Invalid healthcheck scheme")"
[[ $HC_PORT =~ ^[0-9]+$ && $HC_PORT -ge 1 && $HC_PORT -le 65535 ]] \
|| die "$(translate "Invalid healthcheck port")"
+7 -2
View File
@@ -1,9 +1,14 @@
#!/bin/sh
# Marks each start of an OCI container in its console log, so the log can be
# read from the last start. Run by the container's lxc.hook.pre-start.
# Run by the lxc.hook.pre-start of an OCI container. Marks each start in its
# console log, so the log can be read from the last start, and queues the
# check of its DHCP address for when it is up.
case "$1" in
''|*[!0-9]*) exit 0 ;;
esac
printf '\n=== ProxMenux: container started %s ===\n' "$(date '+%Y-%m-%d %H:%M:%S')" \
>> "/var/log/proxmenux/oci/$1.console.log" 2>/dev/null
lease="${0%/*}/oci_dhcp_lease.py"
if [ -r "$lease" ] && command -v systemd-run >/dev/null 2>&1; then
systemd-run --quiet --collect --on-active=20 /usr/bin/python3 "$lease" "$1" >/dev/null 2>&1
fi
exit 0
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Give its DHCP address back to an OCI container that restarted itself.
Proxmox runs the DHCP client of a host-managed interface from the host and
starts it only when it starts the container. A container that reboots from
inside, as Home Assistant OS does from its settings, is started again by
Proxmox without that client and comes back with no address. The start hook of
every OCI container queues this check: once the container is up, the client
that is missing is started the way Proxmox starts it.
The client lives where the container was started from: one started from the
Monitor ends when the Monitor is restarted, and the address would be lost
when its lease runs out. The Monitor asks for every container when it starts.
"""
from __future__ import annotations
import fcntl
from pathlib import Path
import re
import subprocess
import sys
import time
CONFIGS = Path('/etc/pve/lxc')
HOOKS = Path('/var/lib/lxc')
PROC = Path('/proc')
LOCKS = Path('/run/proxmenux')
# Proxmox starts its own client right after the container; one that just
# started is left to it, and to the check its start hook queued.
SETTLE = 30
NAME_RE = re.compile(r'[A-Za-z0-9_.-]{1,15}')
START = 'PVE::LXC::manage_dhclient("start", $ARGV[0], $ARGV[1], $ARGV[2], "/proc/$ARGV[3]/root")'
def managed(config):
"""The host-managed interfaces of a container configuration that take
their address by DHCP: [(name, ip version)]."""
found = []
for line in config.split('\n[', 1)[0].splitlines():
match = re.match(r'net\d+:\s*(.*)', line)
if not match:
continue
options = dict(part.split('=', 1) for part in match.group(1).split(',') if '=' in part)
name = options.get('name', '')
if options.get('host-managed') != '1' or options.get('link_down') == '1' or not NAME_RE.fullmatch(name):
continue
found += [(name, version) for key, version in (('ip', 4), ('ip6', 6)) if options.get(key) == 'dhcp']
return found
def client_running(vmid, name, version):
"""Whether a DHCP client of the interface runs, with or without a lease yet."""
mark = f'{HOOKS}/{vmid}/hook/dhclient{version}-{name}.pid'.encode()
for entry in PROC.glob('[0-9]*/cmdline'):
try:
if mark in entry.read_bytes().split(b'\0'):
return True
except OSError:
continue
return False
def init_pid(vmid):
result = subprocess.run(['lxc-info', '-n', str(vmid), '-pH'], capture_output=True, text=True, check=False, timeout=30)
pid = result.stdout.strip()
return int(pid) if result.returncode == 0 and pid.isdigit() and int(pid) > 0 else None
def running_for(pid):
"""Seconds since the init of the container started."""
try:
return time.time() - (PROC / str(pid)).stat().st_ctime
except OSError:
return 0.0
def containers():
"""The containers of this node with a host-managed interface on DHCP."""
found = []
for path in sorted(CONFIGS.glob('*.conf')):
try:
if path.stem.isdigit() and managed(path.read_text(errors='replace')):
found.append(int(path.stem))
except OSError:
continue
return found
def start_client(vmid, name, version, pid):
# In a scope of its own: the client stays when this check ends.
result = subprocess.run(['systemd-run', '--scope', '--quiet', '--collect', 'perl', '-MPVE::LXC', '-e', START,
str(vmid), str(version), name, str(pid)],
capture_output=True, text=True, check=False, timeout=180)
return result.returncode == 0
def repair(vmid, settle=0):
"""Start the DHCP clients a running container is missing. Returns the
interfaces that got one: [(name, ip version)]."""
try:
config = (CONFIGS / f'{vmid}.conf').read_text(errors='replace')
except OSError:
return []
wanted = managed(config)
if not wanted:
return []
pid = init_pid(vmid)
if pid is None or running_for(pid) < settle:
return []
started = []
for name, version in wanted:
if not client_running(vmid, name, version) and start_client(vmid, name, version, pid):
started.append((name, version))
return started
def check(vmid, settle=0):
with (LOCKS / f'oci-dhcp-lease-{vmid}.lock').open('w') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return
for name, version in repair(vmid, settle):
print(f'CT {vmid}: DHCP client of {name} (IPv{version}) started again', flush=True)
def main():
if len(sys.argv) != 2 or not (sys.argv[1].isdigit() or sys.argv[1] == '--all'):
print('usage: oci_dhcp_lease.py <vmid> | --all', file=sys.stderr)
return 2
LOCKS.mkdir(parents=True, exist_ok=True)
if sys.argv[1] == '--all':
for vmid in containers():
check(vmid, SETTLE)
else:
check(int(sys.argv[1]))
return 0
if __name__ == '__main__':
raise SystemExit(main())
+7 -2
View File
@@ -34,9 +34,13 @@ def snapshot(source, allow_missing=False):
'mode': stat.S_IMODE(info.st_mode)}
def validate_source(source, allow_missing=False):
def validate_source(source, allow_missing=False, declared=False):
"""`declared` is a source the recipe of the application names itself, as a
host monitor does with /proc and /sys: it is not one the user typed."""
valid_path(source)
value = snapshot(source, allow_missing)
if declared:
return dict(value, declared=True)
protected = ('/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64', '/dev', '/proc', '/sys', '/run')
protected += tuple(str(Path(p).resolve()) for p in protected)
resolved = value['resolved_path']
@@ -52,7 +56,8 @@ def same_source(a, b):
def verify_sources(expected):
for source, previous in expected.items():
current = validate_source(source, allow_missing=not previous['exists'])
current = validate_source(source, allow_missing=not previous['exists'],
declared=previous.get('declared', False))
if not same_source(previous, current):
raise ValueError(f"{translate('The operation was stopped because a shared directory changed its identity:')} {source}")
+1
View File
@@ -195,6 +195,7 @@ def resolve_candidate(reference, architecture):
# version inside it moved.
return {'manifest_digest': digest, 'registry_digest': registry_digest,
'layers': [layer['digest'] for layer in json.loads(raw).get('layers', [])],
'size': sum(int(layer.get('size') or 0) for layer in json.loads(raw).get('layers', [])),
'defaults': defaults, 'version': version, 'created': config.get('created')}
+147 -41
View File
@@ -33,6 +33,7 @@ import oci_host_mounts as host_mounts
import oci_accelerators as gpu_devices
import oci_runtime_settings as runtime_settings
import oci_image_cache as image_cache
import oci_work_backup as work_backup
import oci_ui
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error, msg_info2
@@ -48,7 +49,9 @@ BASIC = {'arch', 'cmode', 'console', 'tty', 'cores', 'cpulimit', 'cpuunits', 'de
# The hook that marks each start in that log, set the same way.
'lxc.hook.pre-start'}
# Their output is data (and may hold saved secrets); it is never logged.
DATA_COMMANDS = {('pct', 'config'), ('pvesh', 'get')}
DATA_COMMANDS = {('pct', 'config'), ('pvesh', 'get'), ('pct', 'df')}
# Kept across a modification: the watchdog and where the backups are made.
HOST_PREFERENCES = ('watchdog', 'work_storage')
LOG_DIR = Path(os.environ.get('OCI_LOG_DIR', '/var/log/proxmenux/oci'))
ANSI = re.compile(r'\x1b\[[0-9;?]*[A-Za-z]')
SPINNER_FRAME = re.compile('^ ?[' + ''.join(oci_ui.FRAMES) + ']')
@@ -316,13 +319,49 @@ def mounts(config):
return result
def is_file_bind(mount, files=()):
return mount['type'] == 'host-bind' and (mount['source'] in files or Path(mount['source']).is_file())
def declared_sources(contract):
"""The host paths the recipe of a host monitor mounts on its own."""
if not contract['deployment'].get('host_monitor'):
return set()
profile = contract['template'].get('proxmox', {}).get('installer_profile', {})
return {item['source'] for item in profile.get('host_monitor_mounts', [])}
def profile_keys(deployment):
"""The raw LXC settings the installer writes for what the application
declares, and writes again on the rebuilt container."""
keys = set()
security = deployment.get('security', {})
options = security.get('options') or {}
if options.get('apparmor_profile'):
keys.add('lxc.apparmor.profile')
if options.get('seccomp_profile'):
keys.add('lxc.seccomp.profile')
if options.get('no_new_privileges'):
keys.add('lxc.include')
if options.get('drop_all_capabilities'):
keys.update(('lxc.cap.drop', 'lxc.cap.keep'))
keys.update(f"lxc.prlimit.{limit['name']}" for limit in deployment.get('resources', {}).get('rlimits') or [])
if deployment.get('host_monitor'):
keys.update(('lxc.include', 'lxc.net.0.type', 'lxc.hook.mount'))
return keys
def effective_healthcheck(template):
"""The template's own health check or, when it has none, one derived from
its web address: any HTTP answer below 500 means the application is up."""
profile = template.get('proxmox', {}).get('installer_profile', {})
check = profile.get('startup_healthcheck')
if check or profile.get('haos_healthcheck') or profile.get('host_monitor'):
if check:
return check
if profile.get('haos_healthcheck'):
# Home Assistant OS has a check of its own: Supervisor, Core and the
# services it runs inside. The installer runs it on the new container.
return {'type': 'haos', 'timeout_seconds': int(profile['haos_healthcheck'].get('timeout_seconds', 1200))}
for endpoint in template.get('first_run', {}).get('endpoints', []):
path = str(endpoint.get('path') or '/')
if (endpoint.get('scheme') in ('http', 'https') and str(endpoint.get('port', '')).isdigit()
@@ -336,7 +375,7 @@ def effective_healthcheck(template):
def with_default_healthcheck(contract):
check = effective_healthcheck(contract['template'])
if check:
if check and check.get('type') != 'haos':
contract['template'].setdefault('proxmox', {}).setdefault('installer_profile', {})['startup_healthcheck'] = check
return contract
@@ -365,6 +404,13 @@ def candidate_contract(record, operation, proposal=None):
result = copy.deepcopy(record)
result.update(template=copy.deepcopy(candidate['template']),
deployment=copy.deepcopy(candidate['deployment']))
# What ProxMenux was told about how to treat the application is not part
# of what is being edited: it stays as the record has it now.
for key in HOST_PREFERENCES:
if key in record['deployment']:
result['deployment'][key] = record['deployment'][key]
else:
result['deployment'].pop(key, None)
return with_default_healthcheck(result)
@@ -435,23 +481,32 @@ def preflight(record, candidate, config, coordinated=None):
runtime_keys.add('lxc.mount.entry')
if deployment.get('security', {}).get('sysctls'):
runtime_keys.add('lxc.include')
runtime_keys |= profile_keys(deployment)
files = runtime_settings.file_binds(config)
if files:
runtime_keys.add('lxc.mount.entry')
runtime_settings.check(config, deployment, record['vmid'])
coordinated_keys = {'hookscript', *KEPT_AS_IS} if coordinated else set(KEPT_AS_IS)
if '[' in config.decode() or keys - BASIC - runtime_keys - coordinated_keys - {k for k in keys if re.fullmatch(r'(mp|dev)[0-9]+', k)}:
raise ValueError(translate('The container has advanced Proxmox settings outside the supported profile'))
for plan in (deployment, desired):
security = plan.get('security', {})
if (security.get('unprivileged') is not True
or security.get('options') or plan.get('host_monitor')
or plan.get('resources', {}).get('rlimits')):
raise ValueError(translate('Updates are not available yet in this beta for applications that use '
'a privileged container or advanced LXC settings'))
unprivileged = deployment.get('security', {}).get('unprivileged', True) is not False
if (desired.get('security', {}).get('unprivileged', True) is not False) != unprivileged \
or desired.get('host_monitor') != deployment.get('host_monitor'):
# The owners of the data belong to one mode, and a monitor of the
# host has no network of its own to keep.
raise ValueError(translate('Changing between a privileged and an unprivileged container, or the host monitor mode, requires installing the application again'))
for template, plan in ((record['template'], deployment), (candidate['template'], desired)):
runtime_settings.sysctl_content(plan)
runtime_settings.tmpfs_lines(plan)
gpu_devices.planned(plan)
paths = []
# What the recipe of a host monitor mounts of the host, such as /sys
# and a folder inside it, is read only and laid out by the recipe.
declared = declared_sources({'template': template, 'deployment': plan})
for mount in plan.get('mounts', []):
target = host_mounts.valid_path(mount['container_path'])
if mount['type'] == 'host-bind' and mount['source'] in declared and mount.get('read_only'):
continue
if (any(target == p or target.startswith(p.rstrip('/') + '/')
or p.startswith(target.rstrip('/') + '/') for p in paths)):
raise ValueError(translate('Mount paths must not overlap'))
@@ -465,15 +520,18 @@ def preflight(record, candidate, config, coordinated=None):
else:
raise ValueError(translate('Unsupported mount type'))
paths.append(target)
if cfg.get('unprivileged') != '1' or ':' not in cfg.get('rootfs', ''):
raise ValueError(translate('A managed rootfs and an unprivileged container are required'))
if cfg.get('unprivileged', '0') != ('1' if unprivileged else '0') or ':' not in cfg.get('rootfs', ''):
raise ValueError(translate('The container disks do not match the saved record'))
if desired['rootfs'] != deployment['rootfs']:
raise ValueError(translate('Changing the rootfs or its storage requires a separate migration'))
actual = mounts(config)
gpu_devices.check(config, deployment)
gpu_devices.verify_baseline(record['observed'].get('gpu_devices', {}), deployment)
old = {m['container_path']: m for m in deployment.get('mounts', [])}
new = {m['container_path']: m for m in desired.get('mounts', [])}
recorded_files = {m['source']: bool(m.get('read_only')) for m in deployment.get('mounts', []) if is_file_bind(m, files)}
if files != recorded_files:
raise ValueError(translate('A container mount has a source, backup or permission different from the saved record'))
old = {m['container_path']: m for m in deployment.get('mounts', []) if not is_file_bind(m, files)}
new = {m['container_path']: m for m in desired.get('mounts', []) if not is_file_bind(m, files)}
if set(actual) != set(old):
raise ValueError(translate('The container disks do not match the saved record'))
for target, mount in actual.items():
@@ -500,24 +558,31 @@ def preflight(record, candidate, config, coordinated=None):
check = effective_healthcheck(candidate['template'])
if not check and not coordinated:
raise ValueError(translate('Updates are not available yet for this application in this beta'))
mac = next((item[7:] for item in cfg['net0'].split(',') if item.startswith('hwaddr=')), None)
if deployment.get('host_monitor') and 'net0' not in cfg:
return cfg, actual, None
mac = next((item[7:] for item in cfg.get('net0', '').split(',') if item.startswith('hwaddr=')), None)
if not mac:
raise ValueError(translate('The MAC address of the container cannot be kept'))
return cfg, actual, mac
def freeze_host_sources(record, candidate, acknowledge_external_data):
old = {m['source'] for m in record['deployment'].get('mounts', []) if m['type'] == 'host-bind'}
wanted = {m['source']: m for m in candidate['deployment'].get('mounts', []) if m['type'] == 'host-bind'}
if (old or wanted) and not acknowledge_external_data:
def freeze_host_sources(record, candidate, acknowledge_external_data, files=()):
# A single file mounted from the host holds no data of the application:
# the installer mounts it again, and nothing of it is pinned.
old = {m['source'] for m in record['deployment'].get('mounts', [])
if m['type'] == 'host-bind' and not is_file_bind(m, files)}
wanted = {m['source']: m for m in candidate['deployment'].get('mounts', [])
if m['type'] == 'host-bind' and not is_file_bind(m, files)}
declared = declared_sources(record) | declared_sources(candidate)
if (old | set(wanted)) - declared and not acknowledge_external_data:
raise ValueError(translate('Host data is not restored by the backup; confirm it with --acknowledge-external-data'))
baseline = record['observed'].get('host_bind_sources', {})
original = {p: host_mounts.validate_source(p) for p in old}
original = {p: host_mounts.validate_source(p, declared=p in declared) for p in old}
for source, previous in baseline.items():
if source in original and not host_mounts.same_source(previous, original[source]):
raise ValueError(translate('A shared source does not match its recorded identity'))
desired = {p: original[p] if p in original else
host_mounts.validate_source(p, allow_missing=m.get('create_if_missing') is True)
host_mounts.validate_source(p, allow_missing=m.get('create_if_missing') is True, declared=p in declared)
for p, m in wanted.items()}
if old or wanted:
log('host directories: not included in the backup and not reverted by a recovery')
@@ -529,7 +594,10 @@ def freeze_host_sources(record, candidate, acknowledge_external_data):
def check_runtime_mounts(config, deployment):
runtime_settings.check(config, deployment, deployment['vmid'])
actual = mounts(config)
declared = {m['container_path']: m for m in deployment.get('mounts', [])}
files = runtime_settings.file_binds(config)
if files != {m['source']: bool(m.get('read_only')) for m in deployment.get('mounts', []) if is_file_bind(m, files)}:
raise ValueError(translate('The mounts of the new container do not match the proposal'))
declared = {m['container_path']: m for m in deployment.get('mounts', []) if not is_file_bind(m, files)}
if actual.keys() != declared.keys():
raise ValueError(translate('The mounts of the new container do not match the proposal'))
for target, value in actual.items():
@@ -548,7 +616,7 @@ def pin_host_source(root, vmid, journal, source):
or record['installation_id'] != state['record']['installation_id'] or record['status'] != 'updating'):
raise ValueError(translate('Mount not authorized by the operation'))
expected = state['desired_host_sources'][source]
current = host_mounts.validate_source(source)
current = host_mounts.validate_source(source, declared=expected.get('declared', False))
if ((expected['exists'] and not host_mounts.same_source(expected, current))
or current['resolved_path'] != expected['resolved_path']):
raise ValueError(translate('The host directory changed before it was mounted'))
@@ -582,10 +650,48 @@ def stop(vmid):
raise ValueError(translate('The container did not stop; its disks are not touched'))
def own_addresses(vmid):
"""The IPv4 addresses of the interfaces Proxmox gave the container. One
that runs Docker inside also has the addresses of its inner bridges."""
names = re.findall(r'^net[0-9]+: (?:.*,)?name=([A-Za-z0-9_.-]+)', run('pct', 'config', str(vmid)).decode(), re.M)
found = []
for name in names:
try:
listed = run('lxc-attach', '-n', str(vmid), '-s', 'NETWORK', '--',
'ip', '-4', '-o', 'addr', 'show', 'dev', name, 'scope', 'global').decode()
except RuntimeError:
continue
found += re.findall(r'\binet ([0-9.]+)/', listed)
return found
def haos_healthcheck(vmid, timeout):
import haos_healthcheck as haos
haos.note = log
started, ip = time.monotonic(), None
while ip is None and time.monotonic() - started < 60:
if run('pct', 'status', str(vmid)).strip() != b'status: running':
raise ValueError(translate('The restored service stopped; the recovery is not confirmed'))
ip = next(iter(own_addresses(vmid)), None)
if ip is None:
time.sleep(2)
try:
if ip is None:
raise RuntimeError('no address')
url = haos.wait_ready(vmid, ip, timeout)[0]['url']
except RuntimeError as error:
log(f'restored Home Assistant OS not confirmed: {error}')
raise ValueError(translate('The restored service did not pass its health check'))
log(f'restored service responding: {url}')
return url
def healthcheck(vmid, template):
check = effective_healthcheck(template)
if not check:
return None
if check.get('type') == 'haos':
return haos_healthcheck(vmid, int(check['timeout_seconds']))
if check.get('type') == 'running':
stability = int(check.get('stability_seconds', 20))
started = time.monotonic()
@@ -603,10 +709,12 @@ def healthcheck(vmid, template):
or not 0 <= stability < timeout <= 3600):
raise ValueError(translate('Invalid health check'))
started, stable_since = time.monotonic(), None
# A monitor of the host answers on the network of the host itself.
shared = parse_config(run('pct', 'config', str(vmid))).get('lxc.net.0.type') == 'none'
while time.monotonic() - started < timeout:
if run('pct', 'status', str(vmid)).strip() != b'status: running':
raise ValueError(translate('The restored service stopped; the recovery is not confirmed'))
addresses = run('lxc-info', '-n', str(vmid), '-iH').decode().splitlines()
addresses = ['127.0.0.1'] if shared else run('lxc-info', '-n', str(vmid), '-iH').decode().splitlines()
ip = next((a for a in addresses if re.fullmatch(r'[0-9]+(?:\.[0-9]+){3}', a)), None)
ok = False
if ip:
@@ -820,16 +928,6 @@ def backup_size(vmid):
return total
def require_backup_space(directory, vmids):
"""The data in use on the backed-up volumes, plus a margin, must fit in
`directory`; data that is already compressed does not shrink."""
needed = int(sum(backup_size(vmid) for vmid in vmids) * 1.1) + 1024**3
free = shutil.disk_usage(directory).free
if free < needed:
raise ValueError(f"{translate('Not enough free space for the backup')} "
f"({translate('needed')}: {gib(needed)}, {translate('free')}: {gib(free)}, {directory})")
def prune_backups(root, vmid):
"""The backups of closed operations are removed once the container works
with its new image; their journal and log stay."""
@@ -843,9 +941,7 @@ def prune_backups(root, vmid):
continue
if phase not in ('committed', 'rolled-back'):
continue
for backup in (directory / 'backup').glob('vzdump-lxc-*'):
if backup.is_file() and not backup.is_symlink():
backup.unlink()
work_backup.clear(directory)
def check_archive(archive):
@@ -892,7 +988,8 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
before = run('pct', 'config', str(vmid))
cfg, actual, mac = preflight(record, candidate, before, coordinated)
description = original_description({'record': record, 'before_config': before.decode()})
original_sources, desired_sources = freeze_host_sources(record, candidate, acknowledge_external_data)
original_sources, desired_sources = freeze_host_sources(record, candidate, acknowledge_external_data,
runtime_settings.file_binds(before))
original_gpu = gpu_devices.planned(record['deployment'])
desired_gpu = gpu_devices.planned(candidate['deployment'])
resources = json.loads(run('pvesh', 'get', '/cluster/ha/resources', '--output-format', 'json'))
@@ -912,10 +1009,17 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
for p in set(image['defaults'].get('Volumes') or {}) - non_persistent_image_volumes(candidate['template'])):
raise ValueError(translate('The new image requires additional persistent paths; use Modify'))
directory = instances.location(root, vmid).parent / 'transactions' / uuid.uuid4().hex
# Where the backup goes is settled, and checked for room, before anything
# is stopped.
work = directory if coordinated else work_backup.locate(directory, [vmid], record, vmid)
private_directory(directory)
open_log(directory)
if not coordinated:
require_backup_space(directory, [vmid])
if work != directory:
work_backup.prepare(work)
work_backup.remember(directory, work)
if show:
msg_info2(f"{translate('This backup does not fit on the system disk of the host; it is made on the storage:')} "
f"{record['deployment'].get(work_backup.KEY)}")
journal = directory / 'transaction.json'
runtime_deployment = copy.deepcopy(candidate['deployment'])
runtime_deployment['network']['mac_address'] = mac
@@ -970,7 +1074,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
run('zstd', '-t', backup['archive'])
state.update(backup=backup['archive'], backup_sha256=backup['sha256'])
else:
backup_dir = directory / 'backup'
backup_dir = work / 'backup'
private_directory(backup_dir)
backup = verified_backup(vmid, backup_dir, backup_compression,
translate('The backup could not be identified; the image is not replaced'), show)
@@ -1195,6 +1299,8 @@ def recover(root, journal):
if show:
msg_ok(translate('Recovery completed. The container had not been modified yet.'))
return
if not Path(backup).is_file():
raise ValueError(f"{translate('The backup of this operation cannot be read. If it is on another storage, make it available and recover the operation again:')} {backup}")
if filehash(backup) != state['backup_sha256']:
raise ValueError(translate('The backup was altered; recovery blocked'))
run('gzip' if state.get('backup_compression') == 'gzip' else 'zstd', '-t', backup)
+9 -4
View File
@@ -17,6 +17,7 @@ import subprocess
import sys
import oci_image_cache as image_cache
import oci_work_backup as work_backup
import oci_instances as instances
from oci_installation_state import parse_config
import oci_console
@@ -119,9 +120,11 @@ def _unlink(path):
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)):
its sysctl include, its seccomp profile, 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),
runtime_settings.seccomp_path(vmid)):
_unlink(include)
hook = SNIPPETS / f'proxmenux-rclone-{int(vmid)}-fuse-hook.sh'
if hook.is_file() and not hook.is_symlink():
@@ -179,7 +182,7 @@ def release_shared_host_files(hookscripts):
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',
runtime_settings.seccomp_path(vmid), SNIPPETS / f'proxmenux-rclone-{int(vmid)}-fuse-hook.sh', MONITOR_APPS / f'{int(vmid)}.json',
CLUSTER_RECORDS / f'{int(vmid)}.json',
*oci_console.LOG_DIR.glob(f'{int(vmid)}.console.log*')]
return any(path.exists() for path in paths)
@@ -193,6 +196,7 @@ def sweep_orphans(root):
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'),
(runtime_settings.seccomp_path(0).parent, r'([0-9]+)\.proxmenux-seccomp'),
(oci_console.LOG_DIR, r'([0-9]+)\.console\.log.*'),
(SNIPPETS, r'proxmenux-rclone-([0-9]+)-fuse-hook\.sh'),
(CLUSTER_RECORDS, r'([0-9]+)\.json')):
@@ -347,6 +351,7 @@ def remove(root, vmid):
lifecycle.unlink()
for member in members:
directory = instances.location(root, member).parent
work_backup.forget(root, member)
if directory.is_dir() and not directory.is_symlink():
shutil.rmtree(directory)
msg_ok(translate('Saved record removed'))
+55 -2
View File
@@ -18,6 +18,9 @@ def sysctl_content(deployment):
raise ValueError(translate('Invalid or duplicated network sysctl'))
seen.add(name)
result.append(f'lxc.sysctl.{name} = {value}\n')
# Proxmox does not take this setting in the configuration of a container.
if (deployment.get('security', {}).get('options') or {}).get('no_new_privileges'):
result.append('lxc.no_new_privs = 1\n')
return ''.join(result)
@@ -58,6 +61,28 @@ def legacy_include_path(vmid):
return Path(f'/etc/pve/lxc/{int(vmid)}.proxmenux-sysctls')
# A host monitor shares the process and network namespaces of the host. Proxmox
# takes those two settings from an include, the same for every monitor.
HOST_MONITOR_INCLUDES = (CLUSTER_DIR / 'host-monitor', Path('/etc/pve/lxc/proxmenux-host-monitor'))
HOST_MONITOR_CONTENT = 'lxc.namespace.share.pid = 1\nlxc.namespace.share.net = 1\n'
def host_monitor_include(includes):
"""Whether the includes are the one of a host monitor, unchanged."""
if len(includes) != 1 or Path(includes[0]) not in HOST_MONITOR_INCLUDES:
return False
try:
return Path(includes[0]).read_text() == HOST_MONITOR_CONTENT
except OSError:
return False
def seccomp_path(vmid):
"""The seccomp profile the installer writes for a container that asks for
it relaxed."""
return Path(f'/etc/pve/lxc/{int(vmid)}.proxmenux-seccomp')
def check(config, deployment, vmid):
expected = tmpfs_lines(deployment)
lines = config.decode().splitlines()
@@ -65,6 +90,10 @@ def check(config, deployment, vmid):
if sorted(actual) != sorted(expected):
raise ValueError(translate('The tmpfs mounts of the container differ from the saved record'))
includes = [line.split(': ',1)[1] for line in lines if line.startswith('lxc.include: ')]
if deployment.get('host_monitor'):
if not host_monitor_include(includes):
raise ValueError(translate('The sysctl include is unknown or differs from the saved record'))
return
content = sysctl_content(deployment)
allowed = [[str(include_path(vmid))], [str(legacy_include_path(vmid))]] if content else [[]]
if includes not in allowed:
@@ -78,12 +107,34 @@ def check(config, deployment, vmid):
raise ValueError(translate('The sysctl content was modified outside the saved record'))
FILE_BIND = re.compile(r'(/\S+) \S+ none bind,create=file(,ro)? 0 0')
def file_binds(config):
"""The host files mounted in the container: {source: read only}. Proxmox
takes a directory as a mount point and a single file as a raw LXC entry,
so these are not among the mount points."""
found = {}
for line in config.decode().splitlines():
match = FILE_BIND.fullmatch(line.split(': ', 1)[1]) if line.startswith('lxc.mount.entry: ') else None
if match:
found[match.group(1)] = bool(match.group(2))
return found
def filter_config(config, deployment):
"""The configuration without the entries of the mounts the deployment
declares itself: what is left belongs to the acceleration profile."""
declared = set(tmpfs_lines(deployment))
files = {m['source'] for m in deployment.get('mounts', []) if m.get('type') == 'host-bind'}
def own(line):
entry = line.decode().strip().split(': ', 1)[1]
match = FILE_BIND.fullmatch(entry)
return entry in declared or bool(match and match.group(1) in files)
return b''.join(line for line in config.splitlines(keepends=True)
if not line.startswith(b'lxc.include: ') and not (
line.startswith(b'lxc.mount.entry: ')
and line.decode().strip().split(': ',1)[1] in declared))
line.startswith(b'lxc.mount.entry: ') and own(line)))
def check_recovery(config, state):
@@ -94,6 +145,8 @@ def check_recovery(config, state):
raise ValueError(translate('A tmpfs mount is not part of the journal; recovery blocked'))
if line.startswith('lxc.include: '):
path = Path(line.split(': ', 1)[1])
if any(plan.get('host_monitor') for plan in plans) and host_monitor_include([str(path)]):
continue
if path not in (include_path(state['vmid']), legacy_include_path(state['vmid'])):
raise ValueError(translate('An include is not part of the journal; recovery blocked'))
contents = {sysctl_content(plan) for plan in plans} - {''}
+23 -6
View File
@@ -17,6 +17,7 @@ import oci_instance_transaction as member_tx
import oci_stack_plan
import oci_stack_transaction as stack_tx
import oci_stack_replay as replay
import oci_work_backup as work_backup
from oci_installation_state import image_from_archive, parse_config, private_directory, sha
from oci_update_current import resolve_archive
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error
@@ -251,7 +252,17 @@ class NativeAdapter:
for s in self.services.values() if s['vmid'] != primary_id and not s.get('deferred_setup')]
if spec.get('schema') != 1 or spec.get('dependencies') != expected:
raise ValueError(translate('The dependency hook and the stack recipe differ'))
member_tx.require_backup_space(self.journal.parent, list(self.records))
self.work_base()
def work_base(self):
"""Where the backups of this operation go. It is decided, and checked
for room, until the first backup exists; afterwards it is read back,
so the space the backups themselves take is not asked for again."""
directory = self.journal.parent
if (directory / work_backup.MARK).exists() or any(directory.glob('backup-*')):
return work_backup.recall(directory)
primary = self.plan['primary_vmid']
return work_backup.locate(directory, list(self.records), self.records[primary], primary)
def is_running(self, vmid):
return instances.command('pct', 'status', str(vmid)).strip() == b'status: running'
@@ -331,7 +342,13 @@ class NativeAdapter:
def backup(self, vmid, identity):
self.validate(self.plan)
directory = self.journal.parent / ('backup-%s' % vmid)
base = self.work_base()
if base != self.journal.parent and not (self.journal.parent / work_backup.MARK).exists():
work_backup.prepare(base)
work_backup.remember(self.journal.parent, base)
msg_warn(f"{translate('This backup does not fit on the system disk of the host; it is made on the storage:')} "
f"{self.records[self.plan['primary_vmid']]['deployment'].get(work_backup.KEY)}")
directory = base / ('backup-%s' % vmid)
private_directory(directory)
archive = member_tx.verified_backup(vmid, directory, 'zstd',
translate('The backup of a member could not be identified'))
@@ -339,6 +356,8 @@ class NativeAdapter:
def verify_backups(self, backups):
for backup in backups.values():
if not Path(backup['archive']).is_file():
raise ValueError(f"{translate('The backup of this operation cannot be read. If it is on another storage, make it available and recover the operation again:')} {backup['archive']}")
if member_tx.filehash(backup['archive']) != backup['sha256']:
raise ValueError(translate('A backup was modified'))
member_tx.run('zstd', '-t', backup['archive'])
@@ -579,7 +598,7 @@ class NativeAdapter:
self.release_stages(recovered=state['phase'] == 'rolled-back')
if self.keep_backup and state['phase'] == 'committed':
import oci_keep_backup
for backup in sorted(self.journal.parent.glob('backup-*/vzdump-lxc-*.tar.zst')):
for backup in sorted(work_backup.recall(self.journal.parent).glob('backup-*/vzdump-lxc-*.tar.zst')):
kept = oci_keep_backup.keep(backup, self.keep_backup)
if kept:
msg_ok(f"{translate('Backup kept in')} {self.keep_backup}: {Path(kept).name}")
@@ -621,9 +640,7 @@ class NativeAdapter:
except (OSError, ValueError):
continue
if phase in stack_tx.TERMINAL:
for backup in directory.glob('backup-*/vzdump-lxc-*'):
if backup.is_file() and not backup.is_symlink():
backup.unlink()
work_backup.clear(directory)
# The stack journal of this run, for the summary after a failure.
+9 -2
View File
@@ -12,6 +12,7 @@ import tempfile
import oci_instances as instances
import oci_instance_transaction as transaction
import oci_work_backup as work_backup
from oci_installation_state import image_from_archive, same_image
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error, msg_info2
@@ -102,6 +103,12 @@ def resolve_archive(desired, config, current=None, check=None, required_digest=N
return archive, digest
msg_warn(translate('The cached image is damaged; it will be downloaded again.'))
archive.unlink()
# The compressed layers are what the download writes; nothing was changed yet.
size, free = int(candidate.get('size') or 0), work_backup.available(archive.parent)
if size and free < int(size * 1.1) + 256 * 1024**2:
raise ValueError(f"{translate('The new image does not fit on the storage where images are downloaded:')} {storage}. "
f"{translate('Needed:')} {work_backup.gib(int(size * 1.1) + 256 * 1024**2)}. "
f"{translate('Free:')} {work_backup.gib(free)}. {translate('The application was not modified.')}")
# A download can come back complete yet damaged when the connection drops
# and the transfer resumes; the integrity check catches it, and a second
# download is what repairs it.
@@ -170,8 +177,8 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non
transaction.preflight(record, desired, config)
msg_ok(translate('Container checked'))
current = record['observed']['image'] if operation == 'update' else None
archive, digest = resolve_archive(desired, config, current, lambda: transaction.require_backup_space(
instances.location(instances.ROOT, vmid).parent, [vmid]))
archive, digest = resolve_archive(desired, config, current, lambda: work_backup.locate(
instances.location(instances.ROOT, vmid).parent / 'transactions' / 'check', [vmid], record, vmid))
if archive is None:
msg_ok(translate('The image is already up to date; nothing was changed.'))
return
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""Where the backup taken before changing an OCI application is written.
An update, a modification or a recreation stops the application and takes a
verified backup first, to go back to if the new container does not work; it
is deleted when the operation ends. That backup goes next to the record of
the application, on the system disk of the host. An application with a lot
of data may not fit there: the user can then choose a file storage of the
host that accepts backups, and the choice is kept in the record so later
operations, the scheduled ones included, use it without asking.
"""
from __future__ import annotations
import json
from pathlib import Path
import re
import shutil
import socket
import subprocess
import oci_instances as instances
from oci_ui import translate
FOLDER = 'proxmenux-oci-work'
KEY = 'work_storage'
MARK = 'backup-location'
STORAGE_RE = re.compile(r'[A-Za-z0-9._-]{1,64}')
def gib(size):
return f'{size / 1024**3:.1f} GB'
def required(vmids):
"""The data in use on the backed-up volumes plus a margin: data that is
already compressed does not shrink."""
import oci_instance_transaction as transaction
return int(sum(transaction.backup_size(vmid) for vmid in vmids) * 1.1) + 1024**3
def available(path):
"""Free bytes where `path` is or will be created."""
path = Path(path)
while not path.exists() and path != path.parent:
path = path.parent
return shutil.disk_usage(path).free
def file_storages():
"""The storages of this node that accept backups and are a directory of
the host, with their free space: [{'storage', 'path', 'free'}]."""
node = socket.gethostname().split('.', 1)[0]
listed = subprocess.run(['pvesh', 'get', f'/nodes/{node}/storage', '--content', 'backup', '--enabled', '1',
'--output-format', 'json'], capture_output=True, text=True, timeout=60)
if listed.returncode != 0:
return []
found = []
for entry in json.loads(listed.stdout):
name = str(entry.get('storage') or '')
if not entry.get('active') or not STORAGE_RE.fullmatch(name):
continue
path = storage_path(name)
if path is not None and path.is_dir():
found.append({'storage': name, 'path': path, 'free': available(path)})
return found
def storage_path(storage):
"""The directory of a file storage that accepts backups, or None."""
if not STORAGE_RE.fullmatch(storage or ''):
return None
result = subprocess.run(['pvesh', 'get', f'/storage/{storage}', '--output-format', 'json'],
capture_output=True, text=True, timeout=30)
if result.returncode != 0:
return None
info = json.loads(result.stdout)
if 'backup' not in str(info.get('content', '')).split(',') or not info.get('path'):
return None
return Path(info['path'])
def explain(needed, free, storage=None, storage_free=None):
text = (f"{translate('The backup made before changing the application does not fit on the system disk of the host.')} "
f"{translate('Needed:')} {gib(needed)}. {translate('Free:')} {gib(free)}.")
if storage:
text += (f" {translate('The storage chosen for this backup has no room either:')} {storage}"
+ (f" ({gib(storage_free)})." if storage_free is not None else '.'))
return (f"{text} {translate('The application was not modified.')} "
f"{translate('Free space on the host, or choose another storage for this backup from Manage installed OCI applications.')}")
def locate(default, vmids, record, owner):
"""The directory the backups of one operation are written under: `default`
when they fit there, otherwise a folder of its own on the storage chosen
for the application. Nothing is created. Raises ValueError with what is
needed when there is no room."""
default = Path(default)
needed = required(vmids)
free = available(default)
if free >= needed:
return default
storage = (record.get('deployment') or {}).get(KEY)
path = storage_path(storage) if storage else None
if path is None or not path.is_dir():
raise ValueError(explain(needed, free))
storage_free = available(path)
if storage_free < needed:
raise ValueError(explain(needed, free, storage, storage_free))
return path / FOLDER / str(int(owner)) / default.name
def prepare(base):
"""Create the folder of an operation on another storage and make sure it
can be written, before anything is stopped."""
base = Path(base)
try:
base.mkdir(parents=True, exist_ok=True, mode=0o700)
probe = base / '.write-test'
probe.write_text('')
probe.unlink()
except OSError as error:
raise ValueError(f"{translate('The storage chosen for this backup cannot be written:')} {base} ({error.strerror or error})")
def remember(directory, base):
"""Leave in the folder of the operation where its backups are, when they
are not in that folder: a recovery or a cleanup reads it from there."""
directory, base = Path(directory), Path(base)
if base != directory:
(directory / MARK).write_text(str(base) + '\n')
def recall(directory):
"""Where the backups of the operation kept in `directory` are."""
directory = Path(directory)
try:
text = (directory / MARK).read_text().strip()
except OSError:
return directory
base = Path(text)
return base if base.is_absolute() and FOLDER in base.parts else directory
def clear(directory):
"""Delete the backups of a closed operation, and its folder on another
storage once it is empty. The journal and the log of the operation stay."""
directory = Path(directory)
base = recall(directory)
if base != directory and not base.exists():
return
for backup in list(base.glob('backup/vzdump-lxc-*')) + list(base.glob('backup-*/vzdump-lxc-*')):
if backup.is_file() and not backup.is_symlink():
backup.unlink()
if base == directory:
return
for folder in sorted(base.glob('backup*'), reverse=True):
_remove_empty(folder)
# The folder of the operation and the one of the application, never the
# one shared by every application nor the storage itself.
for folder in (base, base.parent):
if folder.parent.name == FOLDER or folder.parent.parent.name == FOLDER:
_remove_empty(folder)
def _remove_empty(folder):
try:
if folder.is_dir() and not folder.is_symlink():
folder.rmdir()
except OSError:
pass
def forget(root, vmid):
"""Before the record of an application is deleted: remove what its
operations left on another storage."""
transactions = instances.location(root, vmid).parent / 'transactions'
if not transactions.is_dir():
return
for directory in transactions.iterdir():
base = recall(directory)
if base != directory and base.exists() and FOLDER in base.parts:
shutil.rmtree(base, ignore_errors=True)
_remove_empty(base.parent)
def application(root, vmid):
"""The containers of the application `vmid` belongs to."""
record = instances.read(root, vmid)
primary_id = int((record.get('stack_member') or {}).get('primary_vmid') or vmid)
primary = record if primary_id == vmid else instances.read(root, primary_id)
members = [int(member['vmid']) for member in (primary.get('stack') or {}).get('members') or []]
return primary_id, members or [vmid]
def status(root, vmid):
"""What the menu needs to know before an operation: whether the backup
fits on the system disk, the storage chosen earlier and the ones that
have room."""
primary_id, vmids = application(root, vmid)
record = instances.read(root, primary_id)
default = instances.location(root, primary_id).parent
needed, free = required(vmids), available(default)
saved = (record.get('deployment') or {}).get(KEY)
path = storage_path(saved) if saved else None
saved_free = available(path) if path is not None and path.is_dir() else None
return {'needed': needed, 'free': free, 'fits': free >= needed, 'saved': saved,
'saved_fits': saved_free is not None and saved_free >= needed,
'candidates': [{'storage': item['storage'], 'free': item['free']}
for item in file_storages() if item['free'] >= needed]}
def choose(root, vmid, storage):
"""Keep the storage chosen for the backups of an application in the
record of each of its containers. None forgets it."""
import oci_carried_record
if storage is not None and storage_path(storage) is None:
raise ValueError(f"{translate('The storage does not accept backups:')} {storage}")
with instances.locked(root):
_, vmids = application(root, vmid)
for member in vmids:
record = instances.read(root, member)
deployment = record.setdefault('deployment', {})
if storage is None:
deployment.pop(KEY, None)
else:
deployment[KEY] = storage
instances.write(instances.location(root, member), record)
oci_carried_record.carry(root, member, mount_stopped=False)
return vmids