Files
ProxMenux/oci/remote/haos_healthcheck.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

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())