"""Prepare HA configuration and supervise the three Space services.""" import os import json from pathlib import Path import yaml import shutil import signal import subprocess import time CONFIG = Path(os.getenv("HOME_ASSISTANT_CONFIG", "/data/home-assistant")) BACKUP = "/usr/local/bin/backup-homeassistant.py" def prepare_http(text): # Inspect YAML nodes without constructing !include / !secret values. # Preserve all unrelated source text instead of rewriting the whole document. root = yaml.compose(text) if root is None: text = "default_config:\n" root = yaml.compose(text) if not isinstance(root, yaml.MappingNode): raise ValueError("configuration.yaml must contain a top-level mapping") matches = [(key, value) for key, value in root.value if key.value == "http"] if len(matches) > 1: raise ValueError("Duplicate top-level http sections; merge them before startup") settings = ( "http:\n" " server_port: 8123\n" " use_x_forwarded_for: true\n" " trusted_proxies:\n" " - 127.0.0.1\n" " - ::1\n" " - 10.0.0.0/8\n" ) if not matches: result = text.rstrip() + "\n\n" + settings else: key, value = matches[0] if isinstance(value, yaml.MappingNode): kept = [] seen = set() for child_key, child_value in value.value: if child_key.value in seen: raise ValueError("Duplicate key inside http configuration") seen.add(child_key.value) if child_key.value not in {"server_port", "use_x_forwarded_for", "trusted_proxies"}: kept.append(" " + text[child_key.start_mark.index:child_value.end_mark.index].rstrip() + "\n") settings += "".join(kept) elif value.tag != "tag:yaml.org,2002:null": raise ValueError("http must be an inline mapping; move http: !include settings into configuration.yaml") # Replace through the next root key, independent of indentation or CRLF. index = next(i for i, pair in enumerate(root.value) if pair[0] is key) end = root.value[index + 1][0].start_mark.index if index + 1 < len(root.value) else len(text) result = text[:key.start_mark.index] + settings + text[end:] validate_http(result) return result def validate_http(text): root = yaml.compose(text) http = next(value for key, value in root.value if key.value == "http") values = {key.value: value for key, value in http.value} if values["server_port"].value != "8123" or values["use_x_forwarded_for"].tag != "tag:yaml.org,2002:bool" or values["use_x_forwarded_for"].value != "true": raise ValueError("HTTP proxy configuration validation failed") if "127.0.0.1" not in [item.value for item in values["trusted_proxies"].value]: raise ValueError("HTTP proxy configuration must trust local Nginx") def prepare_http_storage(config_dir): """Sync migrated HA HTTP storage before HA starts; preserve all other settings.""" path = config_dir / ".storage" / "http" if not path.exists(): print("No stored HTTP configuration; HA will import YAML settings.", flush=True) return document = json.loads(path.read_text(encoding="utf-8")) version = document.get("version") data = document.get("data") if not isinstance(data, dict): raise ValueError("Invalid .storage/http payload; refusing to start") settings = { "server_port": 8123, "use_x_forwarded_for": True, "trusted_proxies": ["127.0.0.1/32", "::1/128", "10.0.0.0/8"], } if version == 1: data.update(settings) elif version == 2: if not isinstance(data.get("stable"), dict): raise ValueError("Invalid stable HTTP storage configuration") data["stable"].update(settings) data["stable"]["error"] = None data["stable"]["error_message"] = None # Remove an unconfirmed trial so it cannot override the stable config. data["pending"] = None else: raise ValueError(f"Unsupported HTTP storage version {version}; review before migrating") original = path.with_name("http.before-space-proxy.json") if not original.exists(): shutil.copy2(path, original) pending = path.with_name("http.space.tmp") pending.write_text(json.dumps(document, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") pending.replace(path) print(f"HA HTTP storage v{version} verified: port=8123, use_x_forwarded_for=true, trusted_proxy=127.0.0.1", flush=True) def backup(action): result = subprocess.run(["python3", BACKUP, action, "--config", str(CONFIG)], timeout=120) if result.returncode: print(f"Backup operation {action} failed; check Hub secrets and permissions.", flush=True) def main(): CONFIG.mkdir(parents=True, exist_ok=True) config_file = CONFIG / "configuration.yaml" if not config_file.exists(): backup("restore") text = config_file.read_text() if config_file.exists() else "default_config:\n" updated = prepare_http(text) pending = config_file.with_suffix(".yaml.tmp") pending.write_text(updated, encoding="utf-8") pending.replace(config_file) validate_http(config_file.read_text(encoding="utf-8")) print("HA HTTP configuration verified: port=8123, use_x_forwarded_for=true, trusted_proxy=127.0.0.1", flush=True) prepare_http_storage(CONFIG) components = CONFIG / "custom_components" components.mkdir(exist_ok=True) for source in Path("/opt/ha-custom-components").iterdir(): if not (components / source.name).exists(): shutil.copytree(source, components / source.name) stopping = False def stop(signum, frame): nonlocal stopping stopping = True signal.signal(signal.SIGTERM, stop) signal.signal(signal.SIGINT, stop) processes = [] exit_code = 0 try: for command in ( ["hass", "--config", str(CONFIG)], ["/opt/mcp-venv/bin/python", "-m", "uvicorn", "server:app", "--app-dir", "/opt/mcp-server", "--host", "127.0.0.1", "--port", "8000", "--no-access-log"], ["nginx", "-g", "daemon off;"], ): processes.append(subprocess.Popen(command, start_new_session=True)) next_backup = time.monotonic() + 60 enabled = bool(os.getenv("HF_TOKEN") and os.getenv("BACKUP_REPO_ID")) while not stopping: for process in processes: if process.poll() is not None: print(f"Service exited with code {process.returncode}; stopping container.", flush=True) exit_code = process.returncode or 1 stopping = True break if enabled and not stopping and time.monotonic() >= next_backup: try: backup("backup") except Exception as exc: print(f"Periodic backup failed: {type(exc).__name__}", flush=True) next_backup = time.monotonic() + 600 time.sleep(1) finally: for process in reversed(processes): if process.poll() is None: os.killpg(process.pid, signal.SIGTERM) for process in processes: try: process.wait(timeout=25) except subprocess.TimeoutExpired: os.killpg(process.pid, signal.SIGKILL) process.wait() if os.getenv("HF_TOKEN") and os.getenv("BACKUP_REPO_ID"): try: backup("backup") except Exception as exc: print(f"Shutdown backup failed: {type(exc).__name__}", flush=True) return exit_code if __name__ == "__main__": raise SystemExit(main())