mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-07 05:56:40 +00:00
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>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resolve a saved image channel and invoke the native update transaction."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import oci_instances as instances
|
||||
import oci_instance_transaction as transaction
|
||||
from oci_installation_state import image_from_archive
|
||||
from oci_ui import translate, msg_info, msg_ok, msg_error, msg_info2
|
||||
|
||||
|
||||
def repository(reference):
|
||||
repo = reference.split('@', 1)[0]
|
||||
if ':' in repo.rsplit('/', 1)[-1]:
|
||||
repo = repo.rsplit(':', 1)[0]
|
||||
return repo
|
||||
|
||||
|
||||
def run_quiet(args, error, capture=False):
|
||||
"""Runs a helper with its output in the private log; error is the message of a failure."""
|
||||
transaction.log('$ ' + shlex.join(args))
|
||||
process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
env=dict(os.environ, PYTHONPATH=str(Path(__file__).parent)))
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
output, errors = process.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
continue
|
||||
if process.returncode:
|
||||
transaction.log(f' exit {process.returncode}')
|
||||
transaction.log_output(None if capture else output, errors)
|
||||
if process.returncode:
|
||||
raise RuntimeError(error)
|
||||
return output
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
def resolve_archive(desired, config, current=None, check=None):
|
||||
# Shared by individual and coordinated operations; no guest mutation here.
|
||||
# When the registry still serves the current digest nothing is downloaded.
|
||||
reference = desired['template']['container_contract']['image']['reference']
|
||||
architecture = transaction.parse_config(config)['arch']
|
||||
msg_info(translate('Checking the image in the registry...'))
|
||||
transaction.log(f'image: {reference} ({architecture})')
|
||||
code = ('import json,sys; from oci_installation_state import resolve_candidate; '
|
||||
'print(json.dumps(resolve_candidate(sys.argv[1],sys.argv[2])))')
|
||||
candidate = json.loads(run_quiet([sys.executable, '-c', code, reference, architecture],
|
||||
translate('Could not query the image registry'), capture=True))
|
||||
digest = candidate['manifest_digest']
|
||||
if not re.fullmatch(r'sha256:[a-f0-9]{64}', digest):
|
||||
raise ValueError(translate('Invalid registry digest'))
|
||||
msg_ok(f"{translate('Image:')} {reference} ({candidate.get('version') or digest[7:19]})")
|
||||
if digest == current:
|
||||
return None, digest
|
||||
if check:
|
||||
check()
|
||||
storage = desired['deployment']['template_storage']
|
||||
if not re.fullmatch(r'[A-Za-z0-9_-]+', storage):
|
||||
raise ValueError(translate('Invalid template storage'))
|
||||
archive = Path(instances.command('pvesm', 'path',
|
||||
f'{storage}:vztmpl/proxmenux-update-{architecture}-{digest[7:]}.tar').decode().strip())
|
||||
archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
if archive.is_symlink():
|
||||
raise ValueError(translate('Unsafe OCI archive path'))
|
||||
verifier = Path(__file__).with_name('verify_oci_archive.py')
|
||||
cached = archive.exists()
|
||||
if not cached:
|
||||
msg_info(transaction.fit(f"{translate('Downloading the image:')} {reference}"))
|
||||
fd, name = tempfile.mkstemp(prefix='.proxmenux-update-', suffix='.tar', dir=archive.parent)
|
||||
os.close(fd)
|
||||
partial = Path(name)
|
||||
try:
|
||||
run_quiet(['skopeo', 'copy', '--override-arch', architecture,
|
||||
'docker://' + repository(reference) + '@' + digest,
|
||||
'oci-archive:' + str(partial)], translate('Could not download the image'))
|
||||
msg_ok(translate('Image downloaded'))
|
||||
msg_info(translate('Verifying the image integrity...'))
|
||||
run_quiet([sys.executable, str(verifier), str(partial)],
|
||||
translate('The image did not pass the integrity check'))
|
||||
if image_from_archive(str(partial))['manifest_digest'] != digest:
|
||||
raise ValueError(translate('The downloaded image does not match its manifest'))
|
||||
partial.chmod(0o644)
|
||||
os.replace(partial, archive)
|
||||
finally:
|
||||
partial.unlink(missing_ok=True)
|
||||
else:
|
||||
msg_info(translate('Verifying the image integrity...'))
|
||||
run_quiet([sys.executable, str(verifier), str(archive)],
|
||||
translate('The image did not pass the integrity check'))
|
||||
if image_from_archive(str(archive))['manifest_digest'] != digest:
|
||||
raise ValueError(translate('The cached image does not match the current digest'))
|
||||
msg_ok(translate('Using the verified image from the cache') if cached else translate('Image integrity verified'))
|
||||
return archive, digest
|
||||
|
||||
|
||||
def kept_settings(changes, deployment):
|
||||
"""Names of the settings changed in Proxmox that the new container keeps."""
|
||||
labels = {'memory': translate('Memory'), 'swap': translate('Swap'), 'cores': translate('CPU cores'),
|
||||
'cpulimit': translate('CPU cores'), 'cpuunits': translate('CPU priority'),
|
||||
'onboot': translate('Start with Proxmox')}
|
||||
kept = []
|
||||
for key, value in changes.items():
|
||||
section, name = transaction.ADOPTABLE[key]
|
||||
if (deployment.get(section, {}) if section else deployment).get(name) == value:
|
||||
kept.append(labels[key])
|
||||
return kept
|
||||
|
||||
|
||||
def update(vmid, acknowledge_external_data=False, proposal=None):
|
||||
operation = 'recreate' if proposal is not None else 'update'
|
||||
msg_info(translate('Checking the container before the update...') if operation == 'update'
|
||||
else translate('Checking the container before recreating it...'))
|
||||
with instances.locked(instances.ROOT):
|
||||
record = instances.read(instances.ROOT, vmid)
|
||||
if record['status'] != 'installed' or record.get('pending_transaction') or record.get('pending_stack_transaction'):
|
||||
raise ValueError(translate('The instance is not ready to be updated'))
|
||||
config = instances.command('pct', 'config', str(vmid))
|
||||
desired = transaction.candidate_contract(record, operation, proposal)
|
||||
changes = transaction.external_changes(record, config)
|
||||
transaction.preflight(record, desired, config)
|
||||
msg_ok(translate('Container checked'))
|
||||
current = record['observed']['image']['manifest_digest'] if operation == 'update' else None
|
||||
archive, digest = resolve_archive(desired, config, current, lambda: transaction.require_backup_space(
|
||||
instances.location(instances.ROOT, vmid).parent, [vmid]))
|
||||
if archive is None:
|
||||
msg_ok(translate('The image is already up to date; nothing was changed.'))
|
||||
return
|
||||
kept = kept_settings(changes, desired['deployment'])
|
||||
if kept:
|
||||
msg_info2(f"{translate('Keeping the settings changed in Proxmox:')} {', '.join(kept)}")
|
||||
transaction.apply(instances.ROOT, vmid, archive, operation, proposal=proposal,
|
||||
registry_digest=digest, acknowledge_external_data=acknowledge_external_data)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('vmid', type=int)
|
||||
parser.add_argument('--acknowledge-external-data', action='store_true')
|
||||
parser.add_argument('--proposal', type=Path)
|
||||
args = parser.parse_args()
|
||||
if os.geteuid() != 0:
|
||||
parser.error(translate('Root privileges are required'))
|
||||
try:
|
||||
proposal = json.loads(args.proposal.read_text()) if args.proposal else None
|
||||
update(args.vmid, args.acknowledge_external_data, proposal)
|
||||
return 0
|
||||
except BlockingIOError:
|
||||
msg_error(translate('Another OCI operation is using the registry. This operation was not started.'))
|
||||
return 1
|
||||
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
|
||||
transaction.report_error(error, f'update-{args.vmid}')
|
||||
if transaction.pending_journal():
|
||||
transaction.recovery_hint()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user