Adversarial parity review of every ported action against its bash source confirmed 17 real behavioral drifts (of 22 claimed); all fixed here: - tiers/chown: defaults were compositions/apex/tierN (one level too deep); now the tier roots. shared/ base reset is non-recursive again. - compose: --dry-run was swallowed by argparse.REMAINDER; now stripped. - host.write_file: rendered files land 0644 (tempfile's 0600 made fresh systemd units/crowdsec configs owner-only). - systemd.deploy_units: top-level non-hidden files only. - ufw: missing docker/host config dir exits 66; missing /etc/ufw target aborts; empty rules dir warns; dotfile .rules excluded. - sync/packages: Telegram error-trap parity and failed notify exits 1. Same notify-failure exit for backup/run and sync/repository. repository regains the node-dir guard. - tiers/useradd: no more secret-bearing .env.bak per run. - tiers/link: EOF on confirm prompt declines and continues. - extract-traefik-certs: sudo mkdir inside the notify envelope. - generate-happ-subscriptions: realpath parent-exists guards + docker check. - configure/crowdsec: bouncer install goes through install_packages. - lint-docker-compose: malformed/unreadable anchor-IP inputs are lint errors. - core compose: APEX_RESTIC_REPOSITORY now ?error; RESTIC_COMPRESSION per-node via APEX_RESTIC_COMPRESSION (default auto). - sys.service_exists line-anchored; tpl docstring corrected.
96 lines
3.8 KiB
Python
96 lines
3.8 KiB
Python
# engine/lib/sys.py
|
|
"""ctx.sys — command execution + systemd service management (port of root_sys.sh)."""
|
|
from __future__ import annotations
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from typing import List, Optional, Sequence
|
|
|
|
|
|
class Sys:
|
|
def __init__(self, log):
|
|
self.log = log
|
|
|
|
# --- command execution ---
|
|
def run(self, cmd: Sequence[str], check: bool = True, capture: bool = False,
|
|
cwd: Optional[str] = None, env: Optional[dict] = None,
|
|
input: Optional[str] = None) -> subprocess.CompletedProcess:
|
|
return subprocess.run(list(cmd), check=check, cwd=cwd, env=env, text=True,
|
|
input=input,
|
|
stdout=subprocess.PIPE if capture else None,
|
|
stderr=subprocess.PIPE if capture else None)
|
|
|
|
def sudo(self, cmd: Sequence[str], **kw) -> subprocess.CompletedProcess:
|
|
return self.run(["sudo", *cmd], **kw)
|
|
|
|
def ok(self, cmd: Sequence[str], **kw) -> bool:
|
|
"""Run, return True on exit 0, never raising."""
|
|
return self.run(cmd, check=False, **kw).returncode == 0
|
|
|
|
# --- systemd (port of root_sys_*) ---
|
|
def service_exists(self, name: str) -> bool:
|
|
cp = self.run(["systemctl", "list-unit-files", "--type=service"],
|
|
check=False, capture=True)
|
|
return any(line.startswith(f"{name}.service")
|
|
for line in (cp.stdout or "").splitlines())
|
|
|
|
def service_is_active(self, name: str) -> bool:
|
|
return self.ok(["systemctl", "is-active", "--quiet", name])
|
|
|
|
def service_is_enabled(self, name: str) -> bool:
|
|
return self.ok(["systemctl", "is-enabled", "--quiet", name])
|
|
|
|
def start_and_enable(self, name: str) -> bool:
|
|
if not self.service_exists(name):
|
|
self.log.warn(f"Service {name} does not exist on this system")
|
|
return False
|
|
if not self.service_is_active(name):
|
|
self.log.info(f"Starting {name} service...")
|
|
self.sudo(["systemctl", "start", name])
|
|
if not self.service_is_enabled(name):
|
|
self.log.info(f"Enabling {name} service...")
|
|
self.sudo(["systemctl", "enable", name])
|
|
return self.service_is_active(name)
|
|
|
|
def ensure_running(self, name: str) -> None:
|
|
"""Port of root_init_ensure_service_running: enable --now if not active."""
|
|
if not self.service_is_active(name):
|
|
self.log.info(f"Starting {name} service...")
|
|
self.sudo(["systemctl", "enable", "--now", name])
|
|
else:
|
|
self.log.info(f"{name} service already running.")
|
|
|
|
def restart(self, name: str) -> bool:
|
|
self.log.info(f"Restarting {name} service...")
|
|
self.sudo(["systemctl", "restart", name])
|
|
return self.service_is_active(name)
|
|
|
|
def reload(self, name: str, dry_run: bool = False) -> None:
|
|
if dry_run:
|
|
self.log.info(f"[DRY-RUN] systemctl reload {name}")
|
|
return
|
|
self.log.info(f"Reloading {name} configuration...")
|
|
self.sudo(["systemctl", "reload", name])
|
|
|
|
def daemon_reload(self) -> None:
|
|
self.sudo(["systemctl", "daemon-reload"])
|
|
|
|
def wait_for(self, name: str, timeout: int = 30, interval: int = 2) -> bool:
|
|
elapsed = 0
|
|
while elapsed < timeout:
|
|
if self.service_is_active(name):
|
|
return True
|
|
time.sleep(interval)
|
|
elapsed += interval
|
|
return False
|
|
|
|
def install_packages(self, packages: List[str]) -> None:
|
|
"""Port of root_init_install_packages (install if the binary is absent)."""
|
|
for pkg in packages:
|
|
if shutil.which(pkg):
|
|
self.log.info(f"{pkg} already installed.")
|
|
continue
|
|
self.log.info(f"Installing {pkg}...")
|
|
self.sudo(["apt-get", "update", "-y"])
|
|
self.sudo(["apt-get", "install", "-y", pkg])
|