mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-03 20:16:42 +00:00
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())
|