File size: 7,863 Bytes
ad2cbeb 981ffc0 ad2cbeb 8f654f0 ad2cbeb 8f654f0 ad2cbeb 981ffc0 ad2cbeb 8f654f0 981ffc0 ad2cbeb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | """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())
|