mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-30 18:46:40 +00:00
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>
173 lines
6.7 KiB
Python
173 lines
6.7 KiB
Python
"""Verify the nested HAOS runtime without accepting its temporary landing page."""
|
|
import argparse
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import traceback
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
from oci_ui import log, msg_progress, translate
|
|
|
|
|
|
REQUIRED = ('hassio_supervisor', 'homeassistant', 'hassio_cli', 'hassio_dns',
|
|
'hassio_audio', 'hassio_multicast', 'hassio_observer')
|
|
OCI_LOG = os.environ.get('OCI_LOG')
|
|
|
|
|
|
class Pending(RuntimeError):
|
|
"""A known first-boot stage, safe to show without printing container secrets."""
|
|
|
|
|
|
def note(text):
|
|
"""Detail for the run log; without one, for stderr."""
|
|
try:
|
|
if OCI_LOG:
|
|
log(OCI_LOG, text)
|
|
return
|
|
except OSError:
|
|
pass
|
|
print(text, file=sys.stderr, flush=True)
|
|
|
|
|
|
def show_progress(elapsed, timeout, reason):
|
|
text = f"{translate('Waiting for Home Assistant OS...')} {elapsed}/{timeout} s · {reason}"
|
|
width = max(20, shutil.get_terminal_size((80, 24)).columns - 6)
|
|
if len(text) > width:
|
|
text = text[:width - 1] + '…'
|
|
msg_progress(text)
|
|
|
|
|
|
def pending_reason(items, supervisor_logs=''):
|
|
if 'No Supervisor connectivity' in supervisor_logs:
|
|
return translate('Supervisor reports no connectivity; retrying to get versions and install components')
|
|
running = {i.get('Name', '').lstrip('/') for i in items
|
|
if isinstance(i, dict) and i.get('State', {}).get('Running') is True}
|
|
missing = [name for name in REQUIRED if name not in running]
|
|
if missing:
|
|
return translate('Pending components:') + ' ' + ', '.join(missing)
|
|
return translate('Core is still on the initial installation page')
|
|
|
|
|
|
def capture(argv, timeout=15, merge_stderr=False):
|
|
return subprocess.run(argv, check=True, stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT if merge_stderr else subprocess.PIPE, text=True,
|
|
timeout=timeout).stdout
|
|
|
|
|
|
def supervisor_ready(value):
|
|
return (isinstance(value, dict) and value.get('result') == 'ok'
|
|
and isinstance(value.get('data'), dict)
|
|
and value.get('data', {}).get('healthy') is True
|
|
and value.get('data', {}).get('supported') is True)
|
|
|
|
|
|
def containers_ready(items):
|
|
if not isinstance(items, list) or any(not isinstance(item, dict) for item in items):
|
|
return False
|
|
by_name = {item.get('Name', '').lstrip('/'): item for item in items}
|
|
return (all(by_name.get(name, {}).get('State', {}).get('Running') is True
|
|
for name in REQUIRED)
|
|
and 'landingpage' not in by_name['homeassistant'].get('Config', {}).get('Image', '').lower()
|
|
and bool(by_name['homeassistant'].get('Config', {}).get('Image')))
|
|
|
|
|
|
def http_ready(url, timeout=5):
|
|
# Do not route private healthchecks through an environment HTTP proxy.
|
|
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
try:
|
|
with opener.open(url, timeout=timeout) as response:
|
|
return response.status == 200
|
|
except (OSError, urllib.error.URLError):
|
|
return False
|
|
|
|
|
|
def probe(vmid, ip, remaining):
|
|
def run(args):
|
|
return capture(args, timeout=max(0.1, min(15, remaining())))
|
|
if run(['pct', 'status', str(vmid)]).strip() != 'status: running':
|
|
raise RuntimeError(translate('The LXC has stopped'))
|
|
docker = ['pct', 'exec', str(vmid), '--', 'docker', '-H', 'unix:///run/docker-real.sock']
|
|
try:
|
|
containers = json.loads(run(docker + ['inspect', *REQUIRED]))
|
|
except subprocess.CalledProcessError as error:
|
|
# docker inspect returns existing objects and exit 1 for missing names.
|
|
containers = json.loads(error.stdout or '[]')
|
|
if not containers_ready(containers):
|
|
logs = ''
|
|
try:
|
|
logs = capture(docker + ['logs', '--tail', '25', 'hassio_supervisor'],
|
|
timeout=max(0.1, min(15, remaining())), merge_stderr=True)
|
|
except subprocess.SubprocessError:
|
|
pass
|
|
raise Pending(pending_reason(containers, logs))
|
|
supervisor = json.loads(run(docker + ['exec', 'hassio_cli', 'ha', '--raw-json', 'supervisor', 'info']))
|
|
if not supervisor_ready(supervisor):
|
|
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):
|
|
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/'}]
|
|
raise Pending(translate('Core is running, but not responding over HTTP on 80/8123'))
|
|
|
|
|
|
def wait_ready(vmid, ip, timeout):
|
|
started = time.monotonic()
|
|
remaining = lambda: timeout - (time.monotonic() - started)
|
|
last_reason = None
|
|
while remaining() > 0:
|
|
reason = translate('Docker/CLI not available yet or no valid answer')
|
|
try:
|
|
urls = probe(vmid, ip, remaining)
|
|
if urls and remaining() > 0:
|
|
note('HAOS: Supervisor healthy/supported, Core and internal services running.')
|
|
return urls
|
|
except Pending as error:
|
|
reason = str(error)
|
|
except (subprocess.SubprocessError, ValueError, KeyError, TypeError):
|
|
# Missing CLI/containers are expected while upstream pulls its images.
|
|
pass
|
|
elapsed = int(time.monotonic() - started)
|
|
if reason != last_reason:
|
|
note(f'Waiting for HAOS: {elapsed}/{timeout}s; {reason}.')
|
|
last_reason = reason
|
|
show_progress(elapsed, timeout, reason)
|
|
time.sleep(max(0, min(5, remaining())))
|
|
raise RuntimeError(translate('Time is up; Home Assistant OS could not be confirmed as running'))
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('--vmid', type=int, required=True)
|
|
parser.add_argument('--ip', required=True)
|
|
parser.add_argument('--timeout', type=int, default=1200)
|
|
args = parser.parse_args()
|
|
# stdout carries only the JSON result; progress lines go to stderr.
|
|
result_stream = sys.stdout
|
|
sys.stdout = sys.stderr
|
|
try:
|
|
ipaddress.IPv4Address(args.ip)
|
|
if args.vmid < 100 or not 60 <= args.timeout <= 3600:
|
|
parser.error(translate('Invalid VMID or timeout'))
|
|
urls = wait_ready(args.vmid, args.ip, args.timeout)
|
|
except RuntimeError as error:
|
|
note(str(error))
|
|
return 1
|
|
except Exception:
|
|
note(traceback.format_exc())
|
|
return 1
|
|
finally:
|
|
sys.stdout = result_stream
|
|
print(json.dumps(urls))
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|