diff --git a/scripts/run_client.py b/scripts/run_client.py index e272b26..e2c8539 100644 --- a/scripts/run_client.py +++ b/scripts/run_client.py @@ -22,6 +22,7 @@ from __future__ import annotations import argparse import os +import pwd import shlex import shutil import subprocess @@ -223,6 +224,44 @@ def is_first_run(repo_root: Path) -> bool: return True +def _user_exists(username: str) -> bool: + try: + pwd.getpwnam(username) + return True + except KeyError: + return False + + +def ensure_service_user(username: str) -> bool: + if not username: + return False + if _user_exists(username): + return True + useradd = shutil.which("useradd") + if not useradd: + print("useradd not found; cannot create service user.") + return False + shell = "/usr/sbin/nologin" if Path("/usr/sbin/nologin").exists() else "/bin/false" + cmd = [ + useradd, + "--system", + "--no-create-home", + "--shell", + shell, + username, + ] + try: + subprocess.run(cmd, check=True) + print(f"Created service user '{username}'.") + return True + except FileNotFoundError: + print("useradd executable missing; cannot create service user.") + return False + except subprocess.CalledProcessError as exc: + print(f"Failed to create service user '{username}': {exc}") + return False + + # --- Service install/uninstall helpers ----------------------------------- @@ -332,6 +371,7 @@ def install_service_systemd( detached: bool = True, pull: bool = False, workspace_root: Optional[Path] = None, + service_user: Optional[str] = None, ) -> bool: try: helper_path = Path(__file__).resolve() @@ -385,6 +425,7 @@ def install_service_systemd( detached=detached, pull=pull, workspace_root=workspace_root, + service_user=service_user, ) unit_dir = Path.home() / ".config" / "systemd" / "user" @@ -456,81 +497,6 @@ def install_service_systemd( return False -def install_service_systemd_system( - service_name: str, - repo_root: Path, - venv_py: Path, - headless: bool = True, - detached: bool = True, - pull: bool = False, - workspace_root: Optional[Path] = None, -) -> bool: - try: - systemctl = shutil.which("systemctl") - if not systemctl: - print( - "systemctl not available; falling back to crontab @reboot (if present)." - ) - return install_service_cron( - service_name, - repo_root, - venv_py, - headless, - detached, - pull=pull, - workspace_root=workspace_root, - ) - - unit_dir = Path("/etc/systemd/system") - service_file = unit_dir / f"{service_name}.service" - unit_dir.mkdir(parents=True, exist_ok=True) - - local_helper = repo_root / "run_client.py" - if not local_helper.exists(): - local_helper = repo_root / "scripts" / "run_client.py" - target_script = local_helper if local_helper.exists() else Path(__file__).resolve() - - exec_args = f'"{venv_py}" "{target_script}" --detached ' - if headless: - exec_args += "--headless " - if pull: - exec_args += "--pull " - exec_args += f'--repo-root "{repo_root}" ' - - content = ( - "[Unit]\n" - "Description=Medios-Macina Client (system service)\n" - "After=network.target\n\n" - "[Service]\n" - "Type=simple\n" - f"ExecStart={exec_args}\n" - f"WorkingDirectory={repo_root}\n" - "Restart=on-failure\n" - "Environment=PYTHONUNBUFFERED=1\n" - "[Install]\n" - "WantedBy=multi-user.target\n" - ) - service_file.write_text(content, encoding="utf-8") - - for cmd in [ - [systemctl, "daemon-reload"], - [systemctl, "enable", "--now", f"{service_name}.service"], - ]: - subprocess.run(cmd, check=True) - - print(f"system-wide systemd service '{service_name}' enabled and started.") - return True - except subprocess.CalledProcessError as exc: - print("Failed to install system-wide service:", exc) - return False - except PermissionError as exc: - print("Permission denied while writing systemd unit:", exc) - return False - except Exception as exc: - print("system-wide install error:", exc) - return False - - def uninstall_service_systemd(service_name: str) -> bool: try: systemctl = shutil.which("systemctl") @@ -557,6 +523,94 @@ def uninstall_service_systemd(service_name: str) -> bool: return False +def install_service_systemd_system( + service_name: str, + repo_root: Path, + venv_py: Path, + headless: bool = True, + detached: bool = True, + pull: bool = False, + workspace_root: Optional[Path] = None, + service_user: Optional[str] = None, +) -> bool: + try: + systemctl = shutil.which("systemctl") + if not systemctl: + print( + "systemctl not available; falling back to crontab @reboot (if present)." + ) + return install_service_cron( + service_name, + repo_root, + venv_py, + headless, + detached, + pull=pull, + workspace_root=workspace_root, + ) + + if service_user and not ensure_service_user(service_user): + print(f"Unable to prepare service user '{service_user}' for system service.") + return False + + unit_dir = Path("/etc/systemd/system") + service_file = unit_dir / f"{service_name}.service" + unit_dir.mkdir(parents=True, exist_ok=True) + + local_helper = repo_root / "run_client.py" + if not local_helper.exists(): + local_helper = repo_root / "scripts" / "run_client.py" + target_script = local_helper if local_helper.exists() else Path(__file__).resolve() + + exec_args = f'"{venv_py}" "{target_script}" --detached ' + if headless: + exec_args += "--headless " + if pull: + exec_args += "--pull " + exec_args += f'--repo-root "{repo_root}" ' + + service_lines = [ + "[Unit]", + "Description=Medios-Macina Client (system service)", + "After=network.target", + "", + "[Service]", + "Type=simple", + f"ExecStart={exec_args}", + f"WorkingDirectory={repo_root}", + "Restart=on-failure", + "Environment=PYTHONUNBUFFERED=1", + ] + if service_user: + service_lines.append(f"User={service_user}") + service_lines.append(f"Group={service_user}") + service_lines.extend([ + "", + "[Install]", + "WantedBy=multi-user.target", + ]) + content = "\n".join(service_lines) + "\n" + service_file.write_text(content, encoding="utf-8") + + for cmd in [ + [systemctl, "daemon-reload"], + [systemctl, "enable", "--now", f"{service_name}.service"], + ]: + subprocess.run(cmd, check=True) + + print(f"system-wide systemd service '{service_name}' enabled and started.") + return True + except subprocess.CalledProcessError as exc: + print("Failed to install system-wide service:", exc) + return False + except PermissionError as exc: + print("Permission denied while writing systemd unit:", exc) + return False + except Exception as exc: + print("system-wide install error:", exc) + return False + + def install_service_cron( service_name: str, repo_root: Path,