Files
ProxMenux/oci/remote/oci_stack_transaction.py
T
MacRimiandClaude Opus 5 bcabcb618c feat(oci): run official container images as native LXC containers
Adds the OCI manager: an engine that turns a Docker Compose file into an
LXC definition, a catalog of 365 applications drawn from LinuxServer.io
and other container image sources, and a per-instance registry recording
what each container was built from. Reachable from the main menu.

Catalog text is translated like every other string in the project: the
taglines go through translate() and land in lang/*.json, so the entries
read in all eight languages instead of only English.

Translation cache builder:
- a failed translation leaves the key absent rather than writing English,
  which previously made the string count as translated forever
- a result identical to a 3+ word source is rejected, catching a provider
  that silently returns the text it was given
- strings that are nothing but glossary terms keep their source spelling
  instead of being discarded as failures
- no backoff between attempts when the provider is deterministic
- application names are protected so "HAOS One" survives translation
- argos joins the provider list, and the workflow reads the OCI sources

Audit & Report:
- findings that moved in the wrong direction between runs are reported
  alongside the ones that improved
- an accepted risk can carry a review date and is flagged when it falls due
- backup checks explain in plain language what they looked at and what to
  do next

Monitor:
- disks can be excluded from periodic reads, and an idle disk says so
  instead of showing a stale temperature
- per-disk identity survives a controller or enclosure change
- scheduled Borg backups resolve their SSH key from the repository entry
- PVE upgrades log the package list and the resulting dpkg changes

The web build no longer copies scripts/ into public/: the documentation
links to GitHub, so nothing read that folder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 18:24:59 +02:00

210 lines
9.5 KiB
Python

"""Durable coordination protocol; native PVE adapters are supplied explicitly.
This module does not enable stack updates by itself. The adapter must hold the
instance registry lock, verify guest identities on every call, and implement
idempotent restore/publication using the transaction ID. No individual member
may publish its contract from replace(). All adapter results must be JSON data.
"""
import copy
import fcntl
import json
import os
from pathlib import Path
import stat
import uuid
from oci_installation_state import private_directory
from oci_instances import write
import oci_instance_transaction as member_tx
from oci_ui import translate, msg_info, msg_ok, msg_warn
TERMINAL = {'committed', 'rolled-back'}
PHASES = {'prepared', 'preparing', 'stopping', 'backing-up', 'replacing',
'starting', 'checking', 'publishing', 'recovering',
'recovery-failed'} | TERMINAL
def save(path, state, phase):
if state.get('phase') != phase:
member_tx.log(f'stack phase: {phase}')
state['phase'] = phase
write(path, state)
def member(adapter, vmid):
describe = getattr(adapter, 'describe', None)
return describe(vmid) if describe else f'CT {vmid}'
def validate_plan(plan):
if plan.get('operation') not in ('update', 'recreate'):
raise ValueError(translate('Invalid stack operation'))
if plan.get('blockers') or plan.get('missing_members'):
raise ValueError(translate('The stack needs member adaptations or a verification of missing volumes'))
members = plan.get('members', [])
ids = [member['vmid'] for member in members]
if not ids or any(type(vmid) is not int or vmid < 100 for vmid in ids):
raise ValueError(translate('Invalid stack members'))
if len(set(ids)) != len(ids) or plan.get('primary_vmid') not in ids:
raise ValueError(translate('Invalid main member or duplicated members'))
for key in ('start_order', 'stop_order'):
order = plan.get(key, [])
if len(order) != len(ids) or set(order) != set(ids):
raise ValueError(translate('Incomplete stack order'))
if plan['start_order'][-1] != plan['primary_vmid'] or plan['stop_order'][0] != plan['primary_vmid']:
raise ValueError(translate('The main member must stop first and start last'))
def recover_state(path, state, adapter):
"""Recover every private backup once replacement could have begun.
A dependency may receive database writes even when only the frontend was
replaced. Consequently rollback never restores just the failing member.
External host files are deliberately outside this recovery protocol.
"""
if state['phase'] in TERMINAL:
if hasattr(adapter, 'finalize'):
adapter.finalize(state)
return state
ids = {str(vmid) for vmid in state['plan']['start_order']}
if (set(state.get('running', {})) != ids
or any(type(value) is not bool for value in state['running'].values())
or type(state.get('stop_intent')) is not bool
or type(state.get('replacement_intent')) is not bool):
raise ValueError(translate('The journal has an incomplete recovery state'))
if state['replacement_intent'] and (not state['stop_intent'] or set(state.get('backups', {})) != ids):
raise ValueError(translate('Stack backups are missing; a partial restore is not allowed'))
adapter.validate(state['plan'])
if state['replacement_intent'] and hasattr(adapter, 'verify_backups'):
msg_info(translate('Verifying the backups...'))
adapter.verify_backups(state['backups'])
msg_ok(translate('Backups verified'))
save(path, state, 'recovering')
try:
if state['stop_intent']:
msg_info(translate('Stopping the stack...'))
for vmid in state['plan']['stop_order']:
adapter.stop(vmid)
msg_ok(translate('Stack stopped'))
if state['replacement_intent']:
for vmid in state['plan']['start_order']:
backup = state['backups'].get(str(vmid))
if backup is None:
raise ValueError(translate('A stack backup is missing; a partial restore is not allowed'))
for vmid in state['plan']['start_order']:
msg_info(f"{translate('Restoring')} {member(adapter, vmid)}...")
adapter.restore(vmid, state['backups'][str(vmid)], state['id'])
msg_ok(f"{translate('Restored:')} {member(adapter, vmid)}")
msg_info(translate('Restoring the stack records...'))
adapter.restore_contracts(state['plan'], state['id'])
msg_ok(translate('Stack records restored'))
if state['stop_intent']:
msg_info(translate('Returning the containers to their previous state...'))
adapter.restore_running_state(state['running'], state['plan']['start_order'])
msg_ok(translate('Containers returned to their previous state'))
save(path, state, 'rolled-back')
except Exception:
save(path, state, 'recovery-failed')
raise
if hasattr(adapter, 'finalize'):
adapter.finalize(state)
return state
def _apply(path, plan, adapter):
validate_plan(plan)
if path.exists():
raise ValueError(translate('A journal already exists; review or recover it before trying again'))
adapter.validate(plan)
running = {str(vmid): adapter.is_running(vmid) for vmid in plan['start_order']}
if any(type(value) is not bool for value in running.values()):
raise ValueError(translate('Invalid running state'))
state = {'schema_version': 1, 'id': str(uuid.uuid4()),
'plan': copy.deepcopy(plan), 'running': running,
'prepared': {}, 'backups': {}, 'stop_intent': False,
'replacement_intent': False}
save(path, state, 'prepared')
try:
# Resolve/download/verify every candidate before stopping any service.
save(path, state, 'preparing')
for candidate in plan['members']:
state['prepared'][str(candidate['vmid'])] = adapter.prepare(candidate, plan['operation'])
save(path, state, 'preparing')
adapter.validate(plan)
state['stop_intent'] = True
save(path, state, 'stopping')
msg_info(translate('Stopping the stack...'))
for vmid in plan['stop_order']:
adapter.stop(vmid)
msg_ok(translate('Stack stopped'))
save(path, state, 'backing-up')
for vmid in plan['start_order']:
msg_info(f"{translate('Creating a backup of')} {member(adapter, vmid)}...")
state['backups'][str(vmid)] = adapter.backup(vmid, state['id'])
save(path, state, 'backing-up')
msg_ok(f"{translate('Backup created:')} {member(adapter, vmid)}")
# The adapter must verify all archives, free space and device identities.
msg_info(translate('Verifying the backups...'))
adapter.verify_backups(state['backups'])
adapter.validate(plan)
msg_ok(translate('Backups verified'))
state['replacement_intent'] = True
save(path, state, 'replacing')
for vmid in plan['start_order']:
msg_info(f"{translate('Updating')} {member(adapter, vmid)}...")
adapter.replace(vmid, state['prepared'][str(vmid)], state['id'])
msg_ok(f"{translate('Updated:')} {member(adapter, vmid)}")
save(path, state, 'starting')
for vmid in plan['start_order']:
msg_info(f"{translate('Starting')} {member(adapter, vmid)}...")
adapter.start(vmid)
adapter.healthcheck(vmid)
msg_ok(f"{translate('Service responding:')} {member(adapter, vmid)}")
save(path, state, 'checking')
msg_info(translate('Checking the updated stack...'))
adapter.validate_candidates(state)
adapter.restore_running_state(running, plan['start_order'])
msg_ok(translate('Updated stack checked'))
save(path, state, 'publishing')
msg_info(translate('Saving the stack records...'))
adapter.publish(state)
save(path, state, 'committed')
msg_ok(translate('Stack records saved'))
except Exception as error:
member_tx.report_error(error)
try:
error.oci_reported = True
except AttributeError:
pass
if state['stop_intent']:
msg_warn(translate('Restoring the previous state of the stack...'))
recover_state(path, state, adapter)
raise
if hasattr(adapter, 'finalize'):
adapter.finalize(state)
return state
def execute(journal, adapter, plan=None):
"""Apply when plan is supplied; otherwise explicitly recover an old journal."""
path = Path(journal)
private_directory(path.parent)
lock = path.with_name(path.name + '.lock')
if path.is_symlink() or lock.is_symlink():
raise ValueError(translate('Unsafe journal or lock file'))
with lock.open('a') as handle:
lock.chmod(0o600)
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
if plan is not None:
return _apply(path, plan, adapter)
attributes = path.lstat()
if (not stat.S_ISREG(attributes.st_mode) or attributes.st_uid != os.geteuid()
or attributes.st_mode & 0o077):
raise ValueError(translate('The private journal has an unsafe owner or permissions'))
state = json.loads(path.read_text())
if state.get('schema_version') != 1 or state.get('phase') not in PHASES:
raise ValueError(translate('Invalid stack journal'))
validate_plan(state['plan'])
return recover_state(path, state, adapter)