from __future__ import annotations import argparse from pathlib import Path import shlex import subprocess import tarfile import tempfile DEFAULT_DEPLOY_ENV = Path("config/deploy.env") DEFAULT_REMOTE_DIR = "~/ha-deconz-bridge" APP_ARCHIVE_NAME = "ha-deconz-bridge.tar.gz" RUNTIME_ENV_PREFIX = "HA_DECONZ_BRIDGE_" EXCLUDED_DIR_NAMES = { ".git", ".venv", ".pytest_cache", "__pycache__", "config", "old attempt", } EXCLUDED_SUFFIXES = {".pyc"} def main() -> int: parser = argparse.ArgumentParser( description="Deploy ha-deconz-bridge to a Raspberry Pi or similar Linux host." ) parser.add_argument( "--deploy-env", default=str(DEFAULT_DEPLOY_ENV), help="Local deployment env file. Defaults to config/deploy.env.", ) parser.add_argument("--host", help="SSH target, overriding DEPLOY_HOST.") parser.add_argument("--remote-dir", help="Remote install directory, overriding DEPLOY_REMOTE_DIR.") parser.add_argument( "--config", help=( "Local runtime config entry point. All .py files next to it are copied " "to the remote config directory; the entry point is also copied as " "lights_config.py." ), ) parser.add_argument("--service-env", help="Local service env copied to remote config/service.env.") parser.add_argument( "--config-only", action="store_true", help="Only copy config/service env and restart the service.", ) parser.add_argument( "--no-start", action="store_true", help="Deploy files but do not enable, start, or restart the service.", ) parser.add_argument( "--restart-debug", action="store_true", help="Restart the optional web debugger service after deployment.", ) parser.add_argument( "--with-speed", action="store_true", help="Install optional speed dependencies, overriding DEPLOY_INSTALL_SPEED.", ) args = parser.parse_args() repo_root = Path(__file__).resolve().parents[1] deploy_env_path = _resolve_path(repo_root, args.deploy_env) deploy_env = _read_env_file(deploy_env_path) host = args.host or deploy_env.get("DEPLOY_HOST") if not host: raise SystemExit( "Missing DEPLOY_HOST. Copy config.example/deploy.env.example to " "config/deploy.env and edit it, or pass --host." ) remote_dir = args.remote_dir or deploy_env.get("DEPLOY_REMOTE_DIR", DEFAULT_REMOTE_DIR) config_path = _local_file( repo_root, args.config or deploy_env.get("DEPLOY_CONFIG", "config/lights_config.py"), required=True, ) service_env_path = _service_env_path(repo_root, args.service_env, deploy_env) install_speed = args.with_speed or _env_bool(deploy_env.get("DEPLOY_INSTALL_SPEED", "false")) restart_debug = args.restart_debug or _env_bool(deploy_env.get("DEPLOY_RESTART_DEBUG", "false")) _deploy( host=host, remote_dir=remote_dir, repo_root=repo_root, deploy_env=deploy_env, config_path=config_path, service_env_path=service_env_path, config_only=args.config_only, no_start=args.no_start, install_speed=install_speed, restart_debug=restart_debug, ) return 0 def _deploy( *, host: str, remote_dir: str, repo_root: Path, deploy_env: dict[str, str], config_path: Path, service_env_path: Path | None, config_only: bool, no_start: bool, install_speed: bool, restart_debug: bool, ) -> None: print(f"Deploying to {host}:{remote_dir}") _ssh(host, f"mkdir -p {_q(remote_dir)}") install_dir = _remote_realpath(host, remote_dir) remote_app = f"{install_dir}/app" remote_config = f"{install_dir}/config" remote_venv = f"{install_dir}/.venv" _ssh(host, f"mkdir -p {_q(remote_app)} {_q(remote_config)} {_q('~/.config/systemd/user')}") if not config_only: _ssh(host, f"rm -rf {_q(remote_app)} && mkdir -p {_q(remote_app)}") with tempfile.TemporaryDirectory() as temp_dir: archive = Path(temp_dir) / APP_ARCHIVE_NAME _create_archive(repo_root, archive) remote_archive = f"{install_dir}/{APP_ARCHIVE_NAME}" _scp(host, archive, remote_archive) _ssh(host, f"tar -xzf {_q(remote_archive)} -C {_q(remote_app)} && rm -f {_q(remote_archive)}") _copy_runtime_config(host, config_path, remote_config) _write_and_copy_service_env(host, deploy_env, service_env_path, f"{remote_config}/service.env") _install_service_units(host, repo_root, install_dir) if not config_only: _ssh(host, f"python3 -m venv {_q(remote_venv)}") _ssh(host, f"{_q(f'{remote_venv}/bin/pip')} install --upgrade pip") package = f"{remote_app}[speed]" if install_speed else remote_app _ssh(host, f"{_q(f'{remote_venv}/bin/pip')} install -e {_q(package)}") _ssh(host, "systemctl --user daemon-reload") if not no_start: _ssh(host, "systemctl --user enable ha-deconz-bridge.service") _ssh(host, "systemctl --user restart ha-deconz-bridge.service") if restart_debug: _ssh(host, "systemctl --user restart ha-deconz-bridge-debug.service") print() print("Deploy complete.") print(f"Install dir: {install_dir}") print(f"Remote config: {remote_config}/lights_config.py") print(f"Remote env file: {remote_config}/service.env") print("Service unit: ~/.config/systemd/user/ha-deconz-bridge.service") print() print("Useful commands:") print(f" ssh {host} 'systemctl --user status ha-deconz-bridge.service --no-pager'") print(f" ssh {host} 'journalctl --user -u ha-deconz-bridge.service -f'") print(f" ssh {host} 'systemctl --user start ha-deconz-bridge-debug.service'") def _read_env_file(path: Path) -> dict[str, str]: if not path.exists(): return {} values: dict[str, str] = {} for line in path.read_text(encoding="utf-8").splitlines(): stripped = line.strip() if not stripped or stripped.startswith("#"): continue if "=" not in stripped: raise SystemExit(f"Invalid env line in {path}: {line!r}") key, value = stripped.split("=", 1) values[key.strip()] = _strip_env_value(value.strip()) return values def _strip_env_value(value: str) -> str: if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: return value[1:-1] return value def _service_env_path( repo_root: Path, override: str | None, deploy_env: dict[str, str], ) -> Path | None: candidate = override or deploy_env.get("DEPLOY_SERVICE_ENV") if candidate: return _local_file(repo_root, candidate, required=True) default = repo_root / "config" / "service.env" return default if default.exists() else None def _local_file(repo_root: Path, value: str, *, required: bool) -> Path: path = _resolve_path(repo_root, value) if required and not path.is_file(): raise SystemExit(f"Required file not found: {path}") return path def _resolve_path(repo_root: Path, value: str | Path) -> Path: path = Path(value).expanduser() if not path.is_absolute(): path = repo_root / path return path def _write_and_copy_service_env( host: str, deploy_env: dict[str, str], service_env_path: Path | None, remote_path: str, ) -> None: runtime_values = { key: value for key, value in deploy_env.items() if key.startswith(RUNTIME_ENV_PREFIX) } if service_env_path is not None: runtime_values.update(_read_env_file(service_env_path)) if not runtime_values: if _remote_file_exists(host, remote_path): print(f"Keeping existing remote service env: {remote_path}") return raise SystemExit( "No service environment values found and no remote service.env exists. " "Add HA_DECONZ_BRIDGE_* values to config/deploy.env or provide " "DEPLOY_SERVICE_ENV=config/service.env." ) with tempfile.TemporaryDirectory() as temp_dir: local_env = Path(temp_dir) / "service.env" lines = [f"{key}={_quote_env_value(value)}" for key, value in sorted(runtime_values.items())] local_env.write_text("\n".join(lines) + "\n", encoding="utf-8") _scp(host, local_env, remote_path) def _copy_runtime_config(host: str, config_path: Path, remote_config: str) -> None: config_dir = config_path.parent config_files = sorted(path for path in config_dir.glob("*.py") if path.is_file()) if config_path not in config_files: config_files.append(config_path) _ssh(host, f"find {_q(remote_config)} -maxdepth 1 -type f -name '*.py' -delete") for path in config_files: _scp(host, path, f"{remote_config}/{path.name}") if config_path.name != "lights_config.py": _scp(host, config_path, f"{remote_config}/lights_config.py") def _quote_env_value(value: str) -> str: if not value or any(character.isspace() or character in {'"', "'", "\\"} for character in value): escaped = value.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' return value def _install_service_units(host: str, repo_root: Path, install_dir: str) -> None: for unit_name in ("ha-deconz-bridge.service", "ha-deconz-bridge-debug.service"): template = repo_root / "deploy" / unit_name with tempfile.TemporaryDirectory() as temp_dir: unit = Path(temp_dir) / unit_name unit.write_text( template.read_text(encoding="utf-8").replace("__INSTALL_DIR__", install_dir), encoding="utf-8", ) _scp(host, unit, f"~/.config/systemd/user/{unit_name}") def _create_archive(repo_root: Path, archive: Path) -> None: with tarfile.open(archive, "w:gz") as tar: for path in repo_root.rglob("*"): relative = path.relative_to(repo_root) if _excluded(relative, path): continue tar.add(path, arcname=relative, recursive=False) def _excluded(relative: Path, path: Path) -> bool: if any(part in EXCLUDED_DIR_NAMES for part in relative.parts): return True if any(part.endswith(".egg-info") for part in relative.parts): return True if path.is_file() and path.suffix in EXCLUDED_SUFFIXES: return True return False def _env_bool(value: str) -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} def _remote_realpath(host: str, path: str) -> str: result = subprocess.run( ["ssh", host, f"cd {_q(path)} && pwd -P"], check=True, text=True, stdout=subprocess.PIPE, ) return result.stdout.strip() def _remote_file_exists(host: str, path: str) -> bool: return subprocess.run( ["ssh", host, f"test -f {_q(path)}"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ).returncode == 0 def _ssh(host: str, command: str) -> None: subprocess.run(["ssh", host, command], check=True) def _scp(host: str, source: Path, remote_path: str) -> None: subprocess.run(["scp", str(source), f"{host}:{remote_path}"], check=True) def _q(value: str) -> str: if value.startswith("~/"): return "~/" + shlex.quote(value[2:]) return shlex.quote(value) if __name__ == "__main__": raise SystemExit(main())