Download scripts/run-space.py from YYP0502/Bot: direct link, hf CLI and curl.
- Browser
- Download file 7.86 kB
-
https://huggingface.co/spaces/YYP0502/Bot/resolve/main/scripts/run-space.py
- Command line
-
hf download hf://spaces/YYP0502/Bot/scripts/run-space.py
-
curl -L -o run-space.py https://huggingface.co/spaces/YYP0502/Bot/resolve/main/scripts/run-space.py
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()) | |