Bot / scripts /run-space.py
YYP0502's picture
Upload run-space.py
981ffc0 verified
Raw History Blame Contribute Delete
7.86 kB
"""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())