334 lines
11 KiB
Python
334 lines
11 KiB
Python
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 --now ha-deconz-bridge.service")
|
|
if config_only:
|
|
_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())
|