diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 3f6fc41..a30370f 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -22,6 +22,9 @@ jobs: curl -LsSf https://astral.sh/uv/install.sh | sh echo "$HOME/.local/bin" >> $GITHUB_PATH + - name: Install system dependencies + run: sudo apt-get install -y redis-tools + - name: Install dependencies run: pip install -e ".[test]" diff --git a/bench_cli/cli.py b/bench_cli/cli.py index 6a58de9..0e4a0aa 100644 --- a/bench_cli/cli.py +++ b/bench_cli/cli.py @@ -8,25 +8,28 @@ if typing.TYPE_CHECKING: from bench_cli.core.bench import Bench -_OWN_COMMANDS = frozenset([ - "new", - "init", - "start", - "stop", - "restart", - "get-app", - "new-site", - "frappe", - "build", - "update", - "upgrade", - "build-admin", - "setup", - "volume", - "remove-app", - "uninstall-app", - "list-apps", -]) +_OWN_COMMANDS = frozenset( + [ + "new", + "init", + "start", + "stop", + "restart", + "get-app", + "new-site", + "frappe", + "build", + "update", + "upgrade", + "build-admin", + "setup", + "volume", + "remove-app", + "uninstall-app", + "list-apps", + "status", + ] +) _OWN_GROUP_OPTIONS = frozenset(["--verbose", "--yes", "-y", "--bench", "-b", "--help", "-h"]) # Global bench name selected via -b / --bench; set in main() before dispatch. @@ -156,6 +159,7 @@ def _make_parser() -> argparse.ArgumentParser: p_uninstall.add_argument("app", help="App name to uninstall.") sub.add_parser("list-apps", help="List apps installed in the bench.") + sub.add_parser("status", help="Show bench status summary.") p_newsite = sub.add_parser("new-site", help="Create a new site and add it to bench.toml.") p_newsite.add_argument("name", help="Site name (e.g. site2.localhost).") @@ -241,6 +245,7 @@ def main() -> None: _active_bench = args.bench import time + verbose = getattr(args, "verbose", False) _t0 = time.monotonic() try: @@ -342,6 +347,11 @@ def _dispatch(args: argparse.Namespace) -> None: UpgradeCommand().run() + elif cmd == "status": + from bench_cli.commands.status import StatusCommand + + StatusCommand(_load_bench()).run() + elif cmd == "setup": _dispatch_setup(args) @@ -388,7 +398,7 @@ def _dispatch_setup(args: argparse.Namespace) -> None: SetupLetsEncryptCommand(_load_bench()).run() elif setup_cmd == "production": from bench_cli.commands.setup.production import SetupProductionCommand - + SetupProductionCommand(_load_bench()).run() elif setup_cmd == "requirements": from bench_cli.commands.setup.requirements import SetupRequirementsCommand diff --git a/bench_cli/commands/admin.py b/bench_cli/commands/admin.py index d7e815c..0b0d601 100644 --- a/bench_cli/commands/admin.py +++ b/bench_cli/commands/admin.py @@ -9,13 +9,12 @@ from bench_cli.exceptions import BenchError from bench_cli.utils import run_command -_ADMIN_RELEASE_URL = ( - "https://github.com/frappe/bench-cli/releases/download/latest-build/admin-frontend.tar.gz" -) +_ADMIN_RELEASE_URL = "https://github.com/frappe/bench-cli/releases/download/latest-build/admin-frontend.tar.gz" def _cli_root() -> Path: import bench_cli as _pkg + return Path(_pkg.__file__).parent.parent @@ -52,6 +51,7 @@ def run(self) -> None: if not (frontend / "node_modules").exists(): print("Running npm install...") run_command(["npm", "install"], cwd=frontend, stream_output=True) + print("Running npm build") run_command(["npm", "run", "build"], cwd=frontend, stream_output=True) print("\nAdmin frontend rebuilt successfully.") @@ -59,7 +59,4 @@ def _find_frontend(self) -> Path: candidate = _cli_root() / "admin" / "frontend" if (candidate / "package.json").exists(): return candidate - raise BenchError( - "admin/frontend not found. " - "This command requires the bench-cli source directory with admin/frontend/." - ) + raise BenchError("admin/frontend not found. This command requires the bench-cli source directory with admin/frontend/.") diff --git a/bench_cli/commands/drop_site.py b/bench_cli/commands/drop_site.py index 2a4be73..b94dd55 100644 --- a/bench_cli/commands/drop_site.py +++ b/bench_cli/commands/drop_site.py @@ -34,7 +34,7 @@ def _remove_from_bench_toml(self) -> None: write_toml(bench_toml, raw) def _reload_nginx(self) -> None: - if not self.bench.config.nginx.enabled: + if not self.bench.config.production.nginx: return from bench_cli.managers.nginx_manager import NginxManager mgr = NginxManager(self.bench) diff --git a/bench_cli/commands/init.py b/bench_cli/commands/init.py index 069824b..0c4ce36 100644 --- a/bench_cli/commands/init.py +++ b/bench_cli/commands/init.py @@ -13,7 +13,7 @@ def __init__(self, bench: Bench) -> None: self._total_steps = 0 def run(self) -> None: - production = self.bench.config.nginx.enabled + production = self.bench.config.production.nginx volume_enabled = self.bench.config.volume.enabled self._total_steps = 10 + (3 if production else 0) + (1 if volume_enabled else 0) @@ -62,8 +62,8 @@ def run(self) -> None: ProcessManagerFactory.create(self.bench).generate_config() if production: - self._step("Setup supervisor") - self._setup_supervisor() + self._step("Setup process manager") + self._setup_process_manager() self._step("Setup nginx") self._setup_nginx() self._step("Setup Let's Encrypt SSL") @@ -92,6 +92,10 @@ def _install_system_packages(self) -> None: from bench_cli.managers.mariadb_manager import MariaDBManager from bench_cli.platform import get_package_manager, is_linux + pkg = get_package_manager() + if is_linux(): + pkg.update() + mariadb_manager = MariaDBManager(self.bench.config.mariadb) mariadb_manager.install() mariadb_manager.start() @@ -116,13 +120,19 @@ def _write_common_config_for_production(self, production: bool) -> None: existing["dns_multitenant"] = 1 common_config_path.write_text(json.dumps(existing, indent=2)) - def _setup_supervisor(self) -> None: - from bench_cli.platform import get_package_manager - - get_package_manager().install("supervisor") - from bench_cli.managers.supervisor_process_manager import SupervisorProcessManager - - mgr = SupervisorProcessManager(self.bench) + def _setup_process_manager(self) -> None: + if self.bench.config.production.lightweight: + from bench_cli.managers.systemd_process_manager import SystemdProcessManager + mgr = SystemdProcessManager(self.bench) + else: + import subprocess + from bench_cli.platform import get_package_manager, is_linux + pkg = get_package_manager() + if is_linux() and not pkg.is_installed("supervisor"): + pkg.install("supervisor") + subprocess.run(["sudo", "systemctl", "disable", "--now", "supervisor"], check=False) + from bench_cli.managers.supervisor_process_manager import SupervisorProcessManager + mgr = SupervisorProcessManager(self.bench) mgr.install_config() mgr.reload() diff --git a/bench_cli/commands/new.py b/bench_cli/commands/new.py index 703fe74..104e74b 100644 --- a/bench_cli/commands/new.py +++ b/bench_cli/commands/new.py @@ -42,6 +42,14 @@ timeout = 180 password = "{admin_password}" +# ── Production (optional) ───────────────────────────────────────────────── +# Uncomment to enable production mode (nginx + process manager). +# Without this section, 'bench start' uses Procfile-based dev mode. +# +# [production] +# nginx = false # enable nginx reverse proxy +# lightweight = true # true = systemd (lower memory), false = supervisor + # ── Volume (ZFS, optional) ──────────────────────────────────────────────── # Uncomment and configure to use ZFS-based volume management. # Requires Linux and the zfsutils-linux package. diff --git a/bench_cli/commands/new_site.py b/bench_cli/commands/new_site.py index 0b92dca..20453f2 100644 --- a/bench_cli/commands/new_site.py +++ b/bench_cli/commands/new_site.py @@ -38,7 +38,7 @@ def _validate(self) -> None: raise BenchError(f"App '{app}' is not installed. Run 'bench get-app ' first.") def _reload_nginx(self) -> None: - if not self.bench.config.nginx.enabled: + if not self.bench.config.production.nginx: return from bench_cli.managers.nginx_manager import NginxManager mgr = NginxManager(self.bench) diff --git a/bench_cli/commands/restart.py b/bench_cli/commands/restart.py index 13ec822..8620f54 100644 --- a/bench_cli/commands/restart.py +++ b/bench_cli/commands/restart.py @@ -10,7 +10,7 @@ def __init__(self, bench: Bench) -> None: self.bench = bench def run(self) -> None: - if not self.bench.config.nginx.enabled: + if not self.bench.config.production.enabled: raise BenchError("'bench restart' is only available in production mode. Use 'bench stop' and 'bench start' in dev mode.") from bench_cli.managers.supervisor_process_manager import SupervisorProcessManager manager = ProcessManagerFactory.create(self.bench) diff --git a/bench_cli/commands/setup/nginx.py b/bench_cli/commands/setup/nginx.py index cf6222b..82236ef 100644 --- a/bench_cli/commands/setup/nginx.py +++ b/bench_cli/commands/setup/nginx.py @@ -24,9 +24,13 @@ def run(self) -> None: self._print_site_urls() def _validate_nginx_enabled(self) -> None: - if not self.bench.config.nginx.enabled: + if not self.bench.config.production.enabled: raise ConfigError( - "nginx.enabled must be true in bench.toml to run setup nginx." + "[production] is not configured in bench.toml. Add a [production] section to enable production setup." + ) + if not self.bench.config.production.nginx: + raise ConfigError( + "production.nginx must be true in bench.toml to run setup nginx." ) def _ensure_nginx_config_directory(self) -> None: diff --git a/bench_cli/commands/setup/production.py b/bench_cli/commands/setup/production.py index d20e7d1..5fa5ad9 100644 --- a/bench_cli/commands/setup/production.py +++ b/bench_cli/commands/setup/production.py @@ -16,18 +16,33 @@ def __init__(self, bench: "Bench") -> None: def run(self) -> None: self.bench.config.validate() + self._require_production_enabled() self._require_linux() self._write_dns_multitenancy() - self._setup_supervisor() - self._setup_nginx() - self._setup_letsencrypt_if_needed() + if self.bench.config.production.lightweight: + self._setup_systemd() + else: + self._setup_supervisor() + if self.bench.config.production.nginx: + self._setup_nginx() + self._setup_letsencrypt_if_needed() + + self._build_admin_for_production() + self._print_summary() + def _require_production_enabled(self) -> None: + if not self.bench.config.production.enabled: + print( + "Error: [production] is not configured in bench.toml. Add a [production] section to enable production setup.", + file=sys.stderr, + ) + sys.exit(1) + def _require_linux(self) -> None: if not is_linux(): print( - "Error: bench setup production only runs on Linux servers.\n" - "On macOS, use 'bench start' for local development.", + "Error: bench setup production only runs on Linux servers.\nOn macOS, use 'bench start' for local development.", file=sys.stderr, ) sys.exit(1) @@ -41,26 +56,48 @@ def _write_dns_multitenancy(self) -> None: common_config_path.write_text(json.dumps(existing_data, indent=2)) def _setup_supervisor(self) -> None: + import subprocess from bench_cli.platform import get_package_manager - get_package_manager().install("supervisor") + + pkg = get_package_manager() + if not pkg.is_installed("supervisor"): + pkg.install("supervisor") + subprocess.run(["sudo", "systemctl", "disable", "--now", "supervisor"], check=False) from bench_cli.managers.supervisor_process_manager import SupervisorProcessManager + mgr = SupervisorProcessManager(self.bench) mgr.generate_config() mgr.install_config() mgr.reload() + def _setup_systemd(self) -> None: + from bench_cli.managers.systemd_process_manager import SystemdProcessManager + + mgr = SystemdProcessManager(self.bench) + mgr.generate_config() + mgr.install_config() + mgr.reload() + def _setup_nginx(self) -> None: from bench_cli.commands.setup.nginx import SetupNginxCommand + SetupNginxCommand(self.bench).run() def _setup_letsencrypt_if_needed(self) -> None: if not any(site.config.ssl for site in self.bench.sites()): return from bench_cli.commands.setup.letsencrypt import SetupLetsEncryptCommand + SetupLetsEncryptCommand(self.bench).run() + def _build_admin_for_production(self) -> None: + from bench_cli.commands.admin import BuildAdminCommand + + BuildAdminCommand().run() + def _print_summary(self) -> None: from bench_cli.managers.nginx_manager import NginxManager + nginx_manager = NginxManager(self.bench) print("\nProduction setup complete.") print("Sites:") diff --git a/bench_cli/commands/setup/requirements.py b/bench_cli/commands/setup/requirements.py index c41235b..1117aaf 100644 --- a/bench_cli/commands/setup/requirements.py +++ b/bench_cli/commands/setup/requirements.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING -from bench_cli.utils import run_command +from bench_cli.utils import get_yarn_bin, run_command if TYPE_CHECKING: from bench_cli.core.bench import Bench @@ -37,4 +37,4 @@ def _install_js(self) -> None: if not (app.path / "package.json").exists(): continue print(f"Installing JS requirements for {app.config.name}...") - run_command(["yarn", "install"], cwd=app.path, stream_output=True) + run_command([get_yarn_bin(), "install"], cwd=app.path, stream_output=True) diff --git a/bench_cli/commands/setup_config.py b/bench_cli/commands/setup_config.py index 6ba9a36..533cfd4 100644 --- a/bench_cli/commands/setup_config.py +++ b/bench_cli/commands/setup_config.py @@ -20,7 +20,7 @@ def run(self) -> None: print("Updating common_site_config.json...") self.bench.write_common_site_config() - if self.bench.config.nginx.enabled: + if self.bench.config.production.nginx: print("Updating nginx configs...") NginxManager(self.bench).generate_config() print(" Note: run 'bench setup nginx' to reload nginx with the new config.") diff --git a/bench_cli/commands/start.py b/bench_cli/commands/start.py index 3d35093..4d9ecfa 100644 --- a/bench_cli/commands/start.py +++ b/bench_cli/commands/start.py @@ -1,7 +1,6 @@ from __future__ import annotations from bench_cli.core.bench import Bench -from bench_cli.exceptions import BenchError from bench_cli.managers.process_manager import ProcessManagerFactory @@ -10,21 +9,4 @@ def __init__(self, bench: Bench) -> None: self.bench = bench def run(self) -> None: - process_manager = ProcessManagerFactory.create(self.bench) - if self.bench.config.nginx.enabled: - from bench_cli.managers.supervisor_process_manager import SupervisorProcessManager - assert isinstance(process_manager, SupervisorProcessManager) - if not process_manager.supervisor_conf_path.exists(): - raise BenchError( - "Supervisor config not found. " - "Run 'bench setup production' first." - ) - process_manager.generate_config() - process_manager.reload() - else: - if not process_manager.procfile_path.exists(): - raise BenchError( - f"Procfile not found at {process_manager.procfile_path}. " - "Run 'bench init' first to initialise the bench." - ) - process_manager.start() + ProcessManagerFactory.create(self.bench).start() diff --git a/bench_cli/commands/status.py b/bench_cli/commands/status.py new file mode 100644 index 0000000..d08ace8 --- /dev/null +++ b/bench_cli/commands/status.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import shutil +import subprocess +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from bench_cli.core.bench import Bench + + +class StatusCommand: + def __init__(self, bench: "Bench") -> None: + self.bench = bench + + def run(self) -> None: + cfg = self.bench.config + prod = cfg.production + + self._section("Bench") + self._row("Name", cfg.name) + self._row("Python", cfg.python_version) + self._row("Path", str(self.bench.path)) + + if not prod.enabled: + self._row("Mode", "development (Procfile)") + self._print_processes_dev() + else: + mode = "systemd (--user)" if prod.lightweight else "supervisor (bench-local)" + self._row("Mode", f"production [{mode}]") + self._print_processes_prod() + + self._section("Sites") + sites = list(self.bench.sites()) + if sites: + for site in sites: + ssl = " [SSL]" if site.config.ssl else "" + self._row(site.config.name, f"http{'s' if site.config.ssl else ''}://{site.config.name}{ssl}") + else: + print(" (no sites)") + + self._section("Apps") + apps = list(self.bench.apps()) + if apps: + for app in apps: + self._row(app.config.name, app.config.branch) + else: + print(" (no apps cloned)") + + if prod.enabled and prod.nginx: + self._section("Nginx") + nginx_status = self._service_status("nginx") + self._row("Status", nginx_status) + self._row("HTTP port", str(cfg.nginx.http_port)) + self._row("HTTPS port", str(cfg.nginx.https_port)) + + self._section("Redis") + redis = cfg.redis + if redis.is_single_instance: + self._row("Port", str(redis.cache_port)) + else: + self._row("Cache port", str(redis.cache_port)) + self._row("Queue port", str(redis.queue_port)) + self._row("SocketIO port", str(redis.socketio_port)) + + if cfg.admin.enabled: + self._section("Admin") + self._row("URL", f"http://localhost:{cfg.admin.port}") + self._row("Auth", "enabled" if cfg.admin.password else "no password set") + + if cfg.volume.enabled: + self._section("Volume (ZFS)") + self._print_zfs() + + print() + + def _print_processes_dev(self) -> None: + from bench_cli.managers.process_manager import ProcessManager + mgr = ProcessManager(self.bench) + running = mgr.is_running() + self._row("Processes", _ok("running") if running else _dim("stopped")) + + def _print_processes_prod(self) -> None: + from bench_cli.managers.process_manager import ProcessManagerFactory + mgr = ProcessManagerFactory.create(self.bench) + configured = mgr.is_configured() + running = mgr.is_running() if configured else False + self._row("Configured", _ok("yes") if configured else _warn("no (run: bench setup production)")) + self._row("Processes", _ok("running") if running else _dim("stopped")) + + def _print_zfs(self) -> None: + vol = self.bench.config.volume + self._row("Pool", vol.pool) + self._row("Device", vol.device) + self._row("Snapshots", "enabled" if vol.snapshots.enabled else "disabled") + + if not shutil.which("zfs"): + self._row("ZFS data", _warn("zfs binary not found")) + return + + for label, dataset in [ + ("Benches dataset", f"{vol.pool}/benches"), + ("MariaDB dataset", f"{vol.pool}/mariadb"), + ]: + result = subprocess.run( + ["zfs", "list", "-Hp", "-o", "used,available,quota,reservation", dataset], + capture_output=True, + text=True, + ) + if result.returncode == 0: + used, avail, quota, resv = result.stdout.strip().split("\t") + self._row(label, f"used {_fmt_bytes(int(used))} avail {_fmt_bytes(int(avail))} quota {_fmt_bytes(int(quota))} resv {_fmt_bytes(int(resv))}") + else: + self._row(label, _warn("not found")) + + def _service_status(self, service: str) -> str: + result = subprocess.run( + ["systemctl", "is-active", service], + capture_output=True, + text=True, + ) + active = result.stdout.strip() == "active" + return _ok("active") if active else _dim(result.stdout.strip() or "inactive") + + def _section(self, title: str) -> None: + print(f"\n\033[1m{title}\033[0m") + print(" " + "─" * (len(title) + 2)) + + def _row(self, label: str, value: str) -> None: + print(f" {label:<18} {value}") + + +def _ok(text: str) -> str: + return f"\033[32m{text}\033[0m" + + +def _warn(text: str) -> str: + return f"\033[33m{text}\033[0m" + + +def _dim(text: str) -> str: + return f"\033[90m{text}\033[0m" + + +def _fmt_bytes(n: int) -> str: + for unit in ("B", "K", "M", "G", "T"): + if n < 1024: + return f"{n:.0f}{unit}" + n //= 1024 + return f"{n}P" diff --git a/bench_cli/config/bench_config.py b/bench_cli/config/bench_config.py index 73ead9c..ee54855 100644 --- a/bench_cli/config/bench_config.py +++ b/bench_cli/config/bench_config.py @@ -9,6 +9,7 @@ from bench_cli.config.letsencrypt_config import LetsEncryptConfig from bench_cli.config.mariadb_config import MariaDBConfig from bench_cli.config.nginx_config import NginxConfig +from bench_cli.config.production_config import ProductionConfig from bench_cli.config.redis_config import RedisConfig from bench_cli.config.volume_config import BenchesDatasetConfig, MariaDBDatasetConfig, SnapshotConfig, VolumeConfig from bench_cli.config.worker_config import CustomWorkerEntry, WorkerConfig @@ -32,6 +33,7 @@ class BenchConfig: apps: List[AppConfig] = field(default_factory=list) http_port: int = 8000 socketio_port: int = 9000 + production: ProductionConfig = field(default_factory=ProductionConfig) nginx: NginxConfig = field(default_factory=NginxConfig) letsencrypt: LetsEncryptConfig = field(default_factory=LetsEncryptConfig) admin: AdminConfig = field(default_factory=AdminConfig) @@ -60,6 +62,7 @@ def _from_dict(cls, data: dict) -> "BenchConfig": mariadb = MariaDBConfig(**data.get("mariadb", {})) redis = cls._parse_redis(data.get("redis", {})) workers = cls._parse_workers(data.get("workers", {})) + production = cls._parse_production(data.get("production")) nginx = cls._parse_nginx(data.get("nginx", {})) letsencrypt = cls._parse_letsencrypt(data.get("letsencrypt", {})) admin = cls._parse_admin(data.get("admin", {})) @@ -73,6 +76,7 @@ def _from_dict(cls, data: dict) -> "BenchConfig": mariadb=mariadb, redis=redis, workers=workers, + production=production, nginx=nginx, letsencrypt=letsencrypt, admin=admin, @@ -113,11 +117,20 @@ def _parse_workers(data: dict) -> WorkerConfig: custom=custom, ) + @staticmethod + def _parse_production(data: dict | None) -> ProductionConfig: + if data is None: + return ProductionConfig() + return ProductionConfig( + enabled=True, + nginx=data.get("nginx", False), + lightweight=data.get("lightweight", False), + ) + @staticmethod def _parse_nginx(data: dict) -> NginxConfig: config_dir = data.get("config_dir", "/etc/nginx/conf.d") return NginxConfig( - enabled=data.get("enabled", False), http_port=data.get("http_port", 80), https_port=data.get("https_port", 443), config_dir=Path(config_dir), diff --git a/bench_cli/config/nginx_config.py b/bench_cli/config/nginx_config.py index 1d88c37..f961247 100644 --- a/bench_cli/config/nginx_config.py +++ b/bench_cli/config/nginx_config.py @@ -4,7 +4,6 @@ @dataclass class NginxConfig: - enabled: bool = False http_port: int = 80 https_port: int = 443 config_dir: Path = field(default_factory=lambda: Path("/etc/nginx/conf.d")) diff --git a/bench_cli/config/production_config.py b/bench_cli/config/production_config.py new file mode 100644 index 0000000..347fd6f --- /dev/null +++ b/bench_cli/config/production_config.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass +class ProductionConfig: + enabled: bool = False + nginx: bool = False + lightweight: bool = False diff --git a/bench_cli/managers/nginx_manager.py b/bench_cli/managers/nginx_manager.py index 945a515..4124e70 100644 --- a/bench_cli/managers/nginx_manager.py +++ b/bench_cli/managers/nginx_manager.py @@ -294,9 +294,9 @@ def install_config(self) -> None: ) def reload(self) -> None: - run_command(["nginx", "-t"]) + run_command(["sudo", "nginx", "-t"]) if is_linux(): - run_command(["systemctl", "reload", "nginx"]) + run_command(["sudo", "systemctl", "reload", "nginx"]) else: run_command(["nginx", "-s", "reload"]) diff --git a/bench_cli/managers/process_manager.py b/bench_cli/managers/process_manager.py index d28249a..ee9fd3b 100644 --- a/bench_cli/managers/process_manager.py +++ b/bench_cli/managers/process_manager.py @@ -19,8 +19,10 @@ def _cli_root() -> Path: import bench_cli as _pkg + return Path(_pkg.__file__).parent.parent + _COLORS = ["\033[36m", "\033[32m", "\033[33m", "\033[35m", "\033[34m", "\033[96m", "\033[92m", "\033[93m"] _RESET = "\033[0m" @@ -55,7 +57,12 @@ def generate_config(self) -> None: # ── Lifecycle ─────────────────────────────────────────────────────────── + def is_configured(self) -> bool: + return self.procfile_path.exists() + def start(self) -> None: + if not self.is_configured(): + raise BenchError(f"Procfile not found at {self.procfile_path}. Run 'bench init' first.") self.pid_file.write_text(str(os.getpid())) try: self._run_procfile() @@ -161,28 +168,35 @@ def _cleanup_proc_pid_files(self) -> None: # ── Process definitions ───────────────────────────────────────────────── - def _process_definitions(self) -> List[ProcessDefinition]: - definitions = [ + def _prod_process_definitions(self) -> List[ProcessDefinition]: + if self.bench.config.production.lightweight: + num_workers = self.bench.config.workers.default_count + self.bench.config.workers.short_count + self.bench.config.workers.long_count + worker_defs: List[ProcessDefinition] = [self._worker_pool_definition("long,default,short", num_workers)] + else: + worker_defs = [ + *self._worker_definitions("default", self.bench.config.workers.default_count), + *self._worker_definitions("short", self.bench.config.workers.short_count), + *self._worker_definitions("long", self.bench.config.workers.long_count), + ] + defs = [ self._web_definition(), self._socketio_definition(), self._admin_definition(), - *self._worker_definitions("default", self.bench.config.workers.default_count), - *self._worker_definitions("short", self.bench.config.workers.short_count), - *self._worker_definitions("long", self.bench.config.workers.long_count), - *[ - pd - for entry in self.bench.config.workers.custom - for pd in self._worker_definitions(entry.queue, entry.count) - ], + *worker_defs, + *[pd for entry in self.bench.config.workers.custom for pd in self._worker_definitions(entry.queue, entry.count)], ] if self.bench.config.redis.is_single_instance: - definitions.append(self._redis_definition("redis", "redis.conf")) + defs.append(self._redis_definition("redis", "redis.conf")) else: - definitions.append(self._redis_definition("redis_cache", "redis_cache.conf")) - definitions.append(self._redis_definition("redis_queue", "redis_queue.conf")) - definitions.append(self._redis_definition("redis_socketio", "redis_socketio.conf")) - definitions.append(self._admin_frontend_dev_definition()) - return definitions + defs.append(self._redis_definition("redis_cache", "redis_cache.conf")) + defs.append(self._redis_definition("redis_queue", "redis_queue.conf")) + defs.append(self._redis_definition("redis_socketio", "redis_socketio.conf")) + return defs + + def _process_definitions(self) -> List[ProcessDefinition]: + defs = [self._admin_dev_definition() if pd.name == "admin" else pd for pd in self._prod_process_definitions()] + defs.append(self._admin_frontend_dev_definition()) + return defs def _web_definition(self) -> ProcessDefinition: port = self.bench.config.http_port @@ -202,6 +216,14 @@ def _socketio_definition(self) -> ProcessDefinition: log_file=self.bench.logs_path / "socketio.log", ) + def _worker_pool_definition(self, queues: str, num_workers: int) -> ProcessDefinition: + sites = self.bench.sites_path + return ProcessDefinition( + name="worker_pool", + command=f"cd {sites} && {self.bench.env_path}/bin/python -m frappe.utils.bench_helper frappe worker-pool --num-workers {num_workers} --queue {queues}", + log_file=self.bench.logs_path / "worker_pool.log", + ) + def _worker_definitions(self, queue: str, count: int) -> List[ProcessDefinition]: sites = self.bench.sites_path return [ @@ -224,16 +246,19 @@ def _admin_definition(self) -> ProcessDefinition: cli_root = _cli_root() python = AdminEnvManager(cli_root).python cfg = self.bench.config.admin - command = ( - f"PYTHONPATH={cli_root} {python} -m admin.backend.server" - f" --bench-root {self.bench.path}" - f" --port {cfg.port}" - f" --timeout {cfg.timeout}" + return ProcessDefinition( + name="admin", + command=(f"PYTHONPATH={cli_root} {python} -m admin.backend.server --bench-root {self.bench.path} --port {cfg.port} --timeout {cfg.timeout} --no-timeout"), + log_file=self.bench.logs_path / "admin.log", ) - command += " --dev" + + def _admin_dev_definition(self) -> ProcessDefinition: + cli_root = _cli_root() + python = AdminEnvManager(cli_root).python + cfg = self.bench.config.admin return ProcessDefinition( name="admin", - command=command, + command=(f"PYTHONPATH={cli_root} {python} -m admin.backend.server --bench-root {self.bench.path} --port {cfg.port} --timeout {cfg.timeout} --dev"), log_file=self.bench.logs_path / "admin.log", ) @@ -251,7 +276,13 @@ def _admin_frontend_dev_definition(self) -> ProcessDefinition: class ProcessManagerFactory: @staticmethod def create(bench: "Bench") -> ProcessManager: - if bench.config.nginx.enabled: - from bench_cli.managers.supervisor_process_manager import SupervisorProcessManager - return SupervisorProcessManager(bench) - return ProcessManager(bench) + if not bench.config.production.enabled: + return ProcessManager(bench) + + from bench_cli.managers.systemd_process_manager import SystemdProcessManager + from bench_cli.managers.supervisor_process_manager import SupervisorProcessManager + + if bench.config.production.lightweight: + return SystemdProcessManager(bench) + + return SupervisorProcessManager(bench) diff --git a/bench_cli/managers/python_env_manager.py b/bench_cli/managers/python_env_manager.py index 91cc325..c316ebc 100644 --- a/bench_cli/managers/python_env_manager.py +++ b/bench_cli/managers/python_env_manager.py @@ -9,7 +9,7 @@ from bench_cli.exceptions import BenchError from bench_cli.platform import is_macos -from bench_cli.utils import run_command +from bench_cli.utils import get_yarn_bin, run_command if TYPE_CHECKING: from bench_cli.core.app import App @@ -43,15 +43,21 @@ def uninstall_app(self, app_name: str) -> None: run_command([uv, "pip", "uninstall", "--python", python, app_name], stream_output=True) def install_node(self) -> None: - if shutil.which("node"): - if not shutil.which("yarn"): - run_command(["sudo", "npm", "install", "-g", "yarn"]) - return + if not shutil.which("node"): + if is_macos(): + run_command(["brew", "install", "node"]) + else: + self._install_node_linux() + if not shutil.which("yarn"): + self._install_yarn() + + def _install_yarn(self) -> None: if is_macos(): - run_command(["brew", "install", "node"]) + run_command(["npm", "install", "-g", "yarn"]) else: - self._install_node_linux() - run_command(["npm", "install", "-g", "yarn"]) + npm_prefix = Path.home() / ".local" + npm_prefix.mkdir(parents=True, exist_ok=True) + run_command(["npm", "install", "-g", "yarn", "--prefix", str(npm_prefix)]) def install_node_dependencies(self) -> None: for app in self.bench.apps(): @@ -78,7 +84,7 @@ def build_assets_for_app(self, app: "App") -> None: if (app.path / "package.json").exists(): print(f" Installing JS dependencies for {app.config.name}...") sys.stdout.flush() - run_command(["yarn", "install"], cwd=app.path, stream_output=True) + run_command([get_yarn_bin(), "install"], cwd=app.path, stream_output=True) print(f" Building assets for {app.config.name}...") sys.stdout.flush() diff --git a/bench_cli/managers/redis_manager.py b/bench_cli/managers/redis_manager.py index 5441344..95bc88f 100644 --- a/bench_cli/managers/redis_manager.py +++ b/bench_cli/managers/redis_manager.py @@ -42,6 +42,7 @@ def _write_single_config(self) -> None: content = ( f"port {self.config.cache_port}\n" "bind 127.0.0.1\n" + 'save ""\n' ) (self.bench.config_path / "redis.conf").write_text(content) @@ -57,6 +58,7 @@ def _write_queue_config(self) -> None: content = ( f"port {self.config.queue_port}\n" "bind 127.0.0.1\n" + 'save ""\n' ) (self.bench.config_path / "redis_queue.conf").write_text(content) diff --git a/bench_cli/managers/supervisor_process_manager.py b/bench_cli/managers/supervisor_process_manager.py index e7115d4..154fb12 100644 --- a/bench_cli/managers/supervisor_process_manager.py +++ b/bench_cli/managers/supervisor_process_manager.py @@ -1,108 +1,146 @@ from __future__ import annotations -import shutil import os +import subprocess +import time from pathlib import Path -from typing import TYPE_CHECKING -from bench_cli.managers.process_manager import ProcessManager, ProcessDefinition, _cli_root from bench_cli.managers.admin_env_manager import AdminEnvManager +from bench_cli.managers.process_manager import ProcessDefinition, ProcessManager, _cli_root from bench_cli.utils import run_command -if TYPE_CHECKING: - from bench_cli.core.bench import Bench - class SupervisorProcessManager(ProcessManager): - """Manages bench processes via supervisord (used in production).""" + """Manages bench processes via a bench-owned supervisord instance (no sudo required).""" + + @property + def supervisor_dir(self) -> Path: + return self.bench.config_path / "supervisor" @property def supervisor_conf_path(self) -> Path: - return self.bench.config_path / "supervisor" / f"{self.bench.config.name}.conf" + return self.supervisor_dir / "supervisord.conf" + + @property + def supervisor_sock(self) -> Path: + return self.supervisor_dir / "supervisord.sock" @property - def supervisor_include_dir(self) -> Path: - return Path("/etc/supervisor/conf.d") + def supervisor_pid(self) -> Path: + return self.supervisor_dir / "supervisord.pid" + + def _supervisorctl(self) -> list[str]: + return ["supervisorctl", "-c", str(self.supervisor_conf_path)] def generate_config(self) -> None: AdminEnvManager(_cli_root()).ensure() - self.supervisor_conf_path.parent.mkdir(parents=True, exist_ok=True) - conf = self._render_supervisor_conf() - self.supervisor_conf_path.write_text(conf) + self.supervisor_dir.mkdir(parents=True, exist_ok=True) + self.supervisor_conf_path.write_text(self._render_supervisord_conf()) def install_config(self) -> None: - symlink = self.supervisor_include_dir / f"{self.bench.config.name}.conf" - if symlink.exists() or symlink.is_symlink(): - symlink.unlink() + self.supervisor_dir.mkdir(parents=True, exist_ok=True) + + def is_configured(self) -> bool: + return self.supervisor_conf_path.exists() + + def _is_supervisord_alive(self) -> bool: + if not self.supervisor_pid.exists(): + return False try: - os.symlink(self.supervisor_conf_path, symlink) - except PermissionError: - print( - f"Permission denied creating symlink at {symlink}.\n" - f"Run manually:\n" - f" sudo ln -sf {self.supervisor_conf_path} {symlink}\n" - f"Then reload supervisord:\n" - f" sudo supervisorctl reread && sudo supervisorctl update" - ) + pid = int(self.supervisor_pid.read_text().strip()) + os.kill(pid, 0) + return True + except (ValueError, ProcessLookupError, OSError): + print("HERE!") + return False def reload(self) -> None: - run_command(["supervisorctl", "reread"]) - run_command(["supervisorctl", "update"]) + if self._is_supervisord_alive(): + run_command([*self._supervisorctl(), "reread"]) + run_command([*self._supervisorctl(), "update"]) def start(self) -> None: - run_command(["supervisorctl", "start", f"{self.bench.config.name}:*"]) + self.generate_config() + if self._is_supervisord_alive(): + run_command([*self._supervisorctl(), "reread"]) + run_command([*self._supervisorctl(), "update"]) + else: + run_command(["supervisord", "-c", str(self.supervisor_conf_path)]) + run_command([*self._supervisorctl(), "start", f"{self.bench.config.name}:*"]) def stop(self) -> None: - run_command(["supervisorctl", "stop", f"{self.bench.config.name}:*"]) + if self._is_supervisord_alive(): + run_command([*self._supervisorctl(), "shutdown"]) def restart(self) -> None: - run_command(["supervisorctl", "restart", f"{self.bench.config.name}:*"]) + run_command([*self._supervisorctl(), "restart", f"{self.bench.config.name}:*"]) def is_running(self) -> bool: - import subprocess + if not self._is_supervisord_alive(): + return False result = subprocess.run( - ["supervisorctl", "status", f"{self.bench.config.name}:*"], - capture_output=True, text=True, + [*self._supervisorctl(), "status", f"{self.bench.config.name}:*"], + capture_output=True, + text=True, ) return "RUNNING" in result.stdout def reload_web(self) -> None: - """Clear the Frappe asset cache in Redis then restart the web worker.""" - import subprocess cache_port = self.bench.config.redis.cache_port - subprocess.run(["redis-cli", "-p", str(cache_port), "del", "assets_json"], - capture_output=True) + subprocess.run(["redis-cli", "-p", str(cache_port), "del", "assets_json"], capture_output=True) if self.is_running(): print("Restarting web worker to pick up new assets...") - run_command(["supervisorctl", "restart", - f"{self.bench.config.name}:{self.bench.config.name}-web"]) + run_command([*self._supervisorctl(), "restart", f"{self.bench.config.name}:{self.bench.config.name}-web"]) - def _render_supervisor_conf(self) -> str: + def _render_supervisord_conf(self) -> str: defs = self._prod_process_definitions() program_names = ",".join( f"{self.bench.config.name}-{pd.name.replace('_', '-')}" for pd in defs ) - group = f"[group:{self.bench.config.name}]\nprograms={program_names}\n\n" - blocks = [self._render_program(pd, pd.name.replace("_", "-")) for pd in defs] - return group + "".join(blocks) + sections: list[str] = [ + "[unix_http_server]", + f"file={self.supervisor_sock}", + "chmod=0700", + "", + "[supervisord]", + f"logfile={self.bench.logs_path}/supervisord.log", + "logfile_maxbytes=50MB", + "logfile_backups=10", + "loglevel=info", + f"pidfile={self.supervisor_pid}", + "nodaemon=false", + "", + "[rpcinterface:supervisor]", + "supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface", + "", + "[supervisorctl]", + f"serverurl=unix://{self.supervisor_sock}", + "", + f"[group:{self.bench.config.name}]", + f"programs={program_names}", + "", + ] + for pd in defs: + sections.append(self._render_program(pd, pd.name.replace("_", "-"))) + + return "\n".join(sections) def _render_program(self, pd: ProcessDefinition, safe_name: str) -> str: import re + log_dir = self.bench.logs_path cmd = pd.command - # Extract leading VAR=value env assignments env_vars: list[str] = [] while True: - m = re.match(r'^([A-Z_][A-Z0-9_]*)=(\S+)\s+', cmd) + m = re.match(r"^([A-Z_][A-Z0-9_]*)=(\S+)\s+", cmd) if not m: break env_vars.append(f'{m.group(1)}="{m.group(2)}"') cmd = cmd[m.end():] - # Extract leading `cd /dir && ` working-directory prefix directory = "" - m2 = re.match(r'^cd\s+(\S+)\s*&&\s*', cmd) + m2 = re.match(r"^cd\s+(\S+)\s*&&\s*", cmd) if m2: directory = m2.group(1) cmd = cmd[m2.end():] @@ -114,7 +152,6 @@ def _render_program(self, pd: ProcessDefinition, safe_name: str) -> str: "autorestart=true", f"stdout_logfile={log_dir}/{pd.name}.log", f"stderr_logfile={log_dir}/{pd.name}.error.log", - "user=root", "stopasgroup=true", "killasgroup=true", ] @@ -122,40 +159,4 @@ def _render_program(self, pd: ProcessDefinition, safe_name: str) -> str: lines.insert(2, f"directory={directory}") if env_vars: lines.insert(2, f"environment={','.join(env_vars)}") - return "\n".join(lines) + "\n\n" - - def _admin_definition(self) -> ProcessDefinition: - cli_root = _cli_root() - python = AdminEnvManager(cli_root).python - cfg = self.bench.config.admin - command = ( - f"PYTHONPATH={cli_root} {python} -m admin.backend.server" - f" --bench-root {self.bench.path}" - f" --port {cfg.port}" - f" --timeout {cfg.timeout}" - f" --no-timeout" - ) - return ProcessDefinition( - name="admin", - command=command, - log_file=self.bench.logs_path / "admin.log", - ) - - def _prod_process_definitions(self) -> list[ProcessDefinition]: - """Process definitions for production (no dev processes).""" - defs = [ - self._web_definition(), - self._socketio_definition(), - self._admin_definition(), - *self._worker_definitions("default", self.bench.config.workers.default_count), - *self._worker_definitions("short", self.bench.config.workers.short_count), - *self._worker_definitions("long", self.bench.config.workers.long_count), - *[pd for entry in self.bench.config.workers.custom for pd in self._worker_definitions(entry.queue, entry.count)], - ] - if self.bench.config.redis.is_single_instance: - defs.append(self._redis_definition("redis", "redis.conf")) - else: - defs.append(self._redis_definition("redis_cache", "redis_cache.conf")) - defs.append(self._redis_definition("redis_queue", "redis_queue.conf")) - defs.append(self._redis_definition("redis_socketio", "redis_socketio.conf")) - return defs + return "\n".join(lines) + "\n" diff --git a/bench_cli/managers/systemd_process_manager.py b/bench_cli/managers/systemd_process_manager.py new file mode 100644 index 0000000..5814f5f --- /dev/null +++ b/bench_cli/managers/systemd_process_manager.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import Path + +from bench_cli.managers.process_manager import ProcessDefinition, ProcessManager, _cli_root +from bench_cli.utils import run_command + + +class SystemdProcessManager(ProcessManager): + """Manages bench processes via systemd --user (no sudo required).""" + + @property + def systemd_conf_dir(self) -> Path: + return self.bench.config_path / "systemd" + + @property + def user_unit_dir(self) -> Path: + return Path.home() / ".config" / "systemd" / "user" + + def _unit_name(self, service_name: str) -> str: + return f"{self.bench.config.name}-{service_name}.service" + + def _target_name(self) -> str: + return f"{self.bench.config.name}.target" + + def _systemctl_env(self) -> dict: + env = dict(os.environ) + runtime_dir = env.get("XDG_RUNTIME_DIR") + + if not runtime_dir: + env["XDG_RUNTIME_DIR"] = f"/run/user/{os.getuid()}" + + return env + + def _systemctl(self, *args: str) -> list[str]: + return ["systemctl", "--user", *args] + + def generate_config(self) -> None: + from bench_cli.managers.admin_env_manager import AdminEnvManager + + AdminEnvManager(_cli_root()).ensure() + self.systemd_conf_dir.mkdir(parents=True, exist_ok=True) + defs = self._prod_process_definitions() + for pd in defs: + (self.systemd_conf_dir / self._unit_name(pd.name)).write_text(self._render_unit(pd)) + (self.systemd_conf_dir / self._target_name()).write_text(self._render_target(defs)) + + def install_config(self) -> None: + self.user_unit_dir.mkdir(parents=True, exist_ok=True) + defs = self._prod_process_definitions() + units = [self._unit_name(pd.name) for pd in defs] + [self._target_name()] + for unit in units: + src = (self.systemd_conf_dir / unit).resolve() + dst = self.user_unit_dir / unit + if dst.is_symlink() or dst.exists(): + dst.unlink() + dst.symlink_to(src) + env = self._systemctl_env() + try: + run_command(self._systemctl("daemon-reload"), env=env) + run_command(self._systemctl("enable", self._target_name()), env=env) + except Exception: + print( + f"\nNote: Could not reach the user systemd bus for {os.environ.get('USER', 'this user')}.\n" + "If linger is not yet enabled, run once as root:\n" + f" sudo loginctl enable-linger {os.environ.get('USER', '')}\n" + "Then re-run: bench setup production" + ) + + def is_configured(self) -> bool: + result = subprocess.run( + self._systemctl("is-enabled", self._target_name()), + capture_output=True, + env=self._systemctl_env(), + ) + return result.returncode == 0 + + def reload(self) -> None: + run_command(self._systemctl("daemon-reload"), env=self._systemctl_env()) + + def start(self) -> None: + self.generate_config() + self.reload() + run_command(self._systemctl("start", self._target_name()), env=self._systemctl_env()) + + def stop(self) -> None: + run_command(self._systemctl("stop", self._target_name()), env=self._systemctl_env()) + + def restart(self) -> None: + run_command(self._systemctl("restart", self._target_name()), env=self._systemctl_env()) + + def is_running(self) -> bool: + result = subprocess.run( + self._systemctl("is-active", self._target_name()), + capture_output=True, + env=self._systemctl_env(), + ) + return result.returncode == 0 + + def reload_web(self) -> None: + cache_port = self.bench.config.redis.cache_port + subprocess.run( + ["redis-cli", "-p", str(cache_port), "del", "assets_json"], + capture_output=True, + ) + if self.is_running(): + print("Restarting web worker to pick up new assets...") + run_command( + self._systemctl("restart", self._unit_name("web")), + env=self._systemctl_env(), + ) + + def _render_unit(self, pd: ProcessDefinition) -> str: + cmd = pd.command + + env_lines: list[str] = [] + while m := re.match(r"^([A-Z_][A-Z0-9_]*)=(\S+)\s+", cmd): + env_lines.append(f"Environment={m.group(1)}={m.group(2)}") + cmd = cmd[m.end() :] + + working_dir = "" + if m2 := re.match(r"^cd\s+(\S+)\s*&&\s*", cmd): + working_dir = m2.group(1) + cmd = cmd[m2.end() :] + + is_redis = cmd.startswith("redis-server") + lines = [ + "[Unit]", + f"Description={self.bench.config.name} {pd.name}", + f"PartOf={self._target_name()}", + "", + "[Service]", + "Type=simple", + ] + if working_dir: + lines.append(f"WorkingDirectory={working_dir}") + lines += env_lines + lines += [ + f"ExecStart={cmd}", + "Restart=on-failure", + ] + if is_redis: + lines.append("TimeoutStopSec=300") + lines += [ + f"StandardOutput=append:{pd.log_file}", + f"StandardError=append:{pd.log_file}.error.log", + ] + return "\n".join(lines) + "\n" + + def _render_target(self, defs: list[ProcessDefinition]) -> str: + wants = " ".join(self._unit_name(pd.name) for pd in defs) + return ( + "\n".join( + [ + "[Unit]", + f"Description={self.bench.config.name} bench", + f"Wants={wants}", + "", + "[Install]", + "WantedBy=default.target", + ] + ) + + "\n" + ) diff --git a/bench_cli/platform.py b/bench_cli/platform.py index a093d19..88d7383 100644 --- a/bench_cli/platform.py +++ b/bench_cli/platform.py @@ -32,6 +32,11 @@ def install(self, *packages: str) -> None: def is_installed(self, package: str) -> bool: """Return True if the package is already installed.""" + @abstractmethod + def update(self) -> None: + """Update package manager""" + + class AptPackageManager(SystemPackageManager): def install(self, *packages: str) -> None: @@ -46,6 +51,9 @@ def is_installed(self, package: str) -> bool: capture_output=True, ) return result.returncode == 0 + + def update(self): + subprocess.run(["sudo", "apt-get", "-y", "update"]) class BrewPackageManager(SystemPackageManager): @@ -61,6 +69,9 @@ def is_installed(self, package: str) -> bool: capture_output=True, ) return bool(result.stdout.strip()) + + def update(self): + return super().update() def get_package_manager() -> SystemPackageManager: diff --git a/bench_cli/utils.py b/bench_cli/utils.py index d0a284b..0459351 100644 --- a/bench_cli/utils.py +++ b/bench_cli/utils.py @@ -1,8 +1,9 @@ import io +import shutil import subprocess from pathlib import Path -from bench_cli.exceptions import CommandError +from bench_cli.exceptions import BenchError, CommandError def write_toml(path: Path, data: dict) -> None: @@ -42,6 +43,15 @@ def _write_section(obj: dict, prefix: str = "") -> None: path.write_text(out.getvalue()) +def get_yarn_bin() -> str: + if yarn := shutil.which("yarn"): + return yarn + local_yarn = Path.home() / ".local" / "bin" / "yarn" + if local_yarn.exists(): + return str(local_yarn) + raise BenchError("yarn not found — run bench init to install it.") + + def run_command( argv: list[str], cwd: Path | None = None, diff --git a/tests/test_commands.py b/tests/test_commands.py index 5a0f017..9f9b91e 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -403,10 +403,11 @@ def test_requirements_installs_js_for_app_with_package_json(tmp_path: Path) -> N (app_dir / ".git").mkdir() (app_dir / "package.json").write_text('{"name": "myapp"}\n') - with patch("bench_cli.commands.setup.requirements.run_command") as mock_rc: - SetupRequirementsCommand(bench)._install_js() - mock_rc.assert_called_once() - assert mock_rc.call_args[0][0] == ["yarn", "install"] + with patch("bench_cli.commands.setup.requirements.get_yarn_bin", return_value="yarn"): + with patch("bench_cli.commands.setup.requirements.run_command") as mock_rc: + SetupRequirementsCommand(bench)._install_js() + mock_rc.assert_called_once() + assert mock_rc.call_args[0][0] == ["yarn", "install"] # ── UpdateCommand ───────────────────────────────────────────────────────────── diff --git a/tests/test_managers_extra.py b/tests/test_managers_extra.py index 23cab73..a645d21 100644 --- a/tests/test_managers_extra.py +++ b/tests/test_managers_extra.py @@ -4,8 +4,6 @@ from pathlib import Path from unittest.mock import MagicMock, patch -import pytest - from bench_cli.config.app_config import AppConfig from bench_cli.config.bench_config import BenchConfig from bench_cli.config.mariadb_config import MariaDBConfig @@ -175,8 +173,16 @@ def test_supervisor_render_program_no_prefix(tmp_path: Path) -> None: def test_supervisor_render_conf_has_group_section(tmp_path: Path) -> None: mgr = _make_supervisor_manager(tmp_path) with patch.object(mgr, "_prod_process_definitions", return_value=[]): - conf = mgr._render_supervisor_conf() - assert f"[group:test-bench]" in conf + conf = mgr._render_supervisord_conf() + assert "[group:test-bench]" in conf + + +def test_supervisor_render_conf_has_unix_http_server(tmp_path: Path) -> None: + mgr = _make_supervisor_manager(tmp_path) + with patch.object(mgr, "_prod_process_definitions", return_value=[]): + conf = mgr._render_supervisord_conf() + assert "[unix_http_server]" in conf + assert f"file={mgr.supervisor_sock}" in conf def test_supervisor_render_conf_program_names_in_group(tmp_path: Path) -> None: @@ -188,19 +194,163 @@ def test_supervisor_render_conf_program_names_in_group(tmp_path: Path) -> None: ProcessDefinition("worker_default_1", "cmd_worker", tmp_path / "logs" / "w.log"), ] with patch.object(mgr, "_prod_process_definitions", return_value=fake_defs): - conf = mgr._render_supervisor_conf() + conf = mgr._render_supervisord_conf() assert "test-bench-web" in conf assert "test-bench-worker-default-1" in conf def test_supervisor_conf_path(tmp_path: Path) -> None: mgr = _make_supervisor_manager(tmp_path) - assert mgr.supervisor_conf_path == tmp_path / "config" / "supervisor" / "test-bench.conf" + assert mgr.supervisor_conf_path == tmp_path / "config" / "supervisor" / "supervisord.conf" + + +def test_supervisor_sock_path(tmp_path: Path) -> None: + mgr = _make_supervisor_manager(tmp_path) + assert mgr.supervisor_sock == tmp_path / "config" / "supervisor" / "supervisord.sock" + + +def test_supervisor_pid_path(tmp_path: Path) -> None: + mgr = _make_supervisor_manager(tmp_path) + assert mgr.supervisor_pid == tmp_path / "config" / "supervisor" / "supervisord.pid" def test_supervisor_generate_config_writes_file(tmp_path: Path) -> None: mgr = _make_supervisor_manager(tmp_path) with patch("bench_cli.managers.supervisor_process_manager.AdminEnvManager"): - with patch.object(mgr, "_render_supervisor_conf", return_value="[group:test-bench]\nprograms=\n\n"): + with patch.object(mgr, "_render_supervisord_conf", return_value="[group:test-bench]\nprograms=\n\n"): mgr.generate_config() assert mgr.supervisor_conf_path.exists() + + +def test_supervisor_render_conf_no_user_directive(tmp_path: Path) -> None: + from bench_cli.managers.process_manager import ProcessDefinition + + mgr = _make_supervisor_manager(tmp_path) + fake_defs = [ProcessDefinition("web", "cmd_web", tmp_path / "logs" / "web.log")] + with patch.object(mgr, "_prod_process_definitions", return_value=fake_defs): + conf = mgr._render_supervisord_conf() + assert "user=" not in conf + + +def test_supervisor_is_configured_false_when_no_conf(tmp_path: Path) -> None: + mgr = _make_supervisor_manager(tmp_path) + assert mgr.is_configured() is False + + +def test_supervisor_is_configured_true_when_conf_exists(tmp_path: Path) -> None: + mgr = _make_supervisor_manager(tmp_path) + mgr.supervisor_conf_path.write_text("[supervisord]\n") + assert mgr.is_configured() is True + + +def test_supervisor_supervisorctl_uses_local_conf(tmp_path: Path) -> None: + mgr = _make_supervisor_manager(tmp_path) + cmd = mgr._supervisorctl() + assert cmd == ["supervisorctl", "-c", str(mgr.supervisor_conf_path)] + + +# ── SystemdProcessManager ───────────────────────────────────────────────────── + + +def _make_systemd_manager(tmp_path: Path): + from bench_cli.managers.systemd_process_manager import SystemdProcessManager + bench = make_bench(tmp_path) + return SystemdProcessManager(bench) + + +def test_systemd_unit_name(tmp_path: Path) -> None: + mgr = _make_systemd_manager(tmp_path) + assert mgr._unit_name("web") == "test-bench-web.service" + + +def test_systemd_target_name(tmp_path: Path) -> None: + mgr = _make_systemd_manager(tmp_path) + assert mgr._target_name() == "test-bench.target" + + +def test_systemd_user_unit_dir(tmp_path: Path) -> None: + mgr = _make_systemd_manager(tmp_path) + assert mgr.user_unit_dir == Path.home() / ".config" / "systemd" / "user" + + +def test_systemd_systemctl_cmd_includes_user_flag(tmp_path: Path) -> None: + mgr = _make_systemd_manager(tmp_path) + assert mgr._systemctl("start", "foo.target") == ["systemctl", "--user", "start", "foo.target"] + + +def test_systemd_env_sets_xdg_runtime_dir(tmp_path: Path) -> None: + import os + mgr = _make_systemd_manager(tmp_path) + env = mgr._systemctl_env() + assert env["XDG_RUNTIME_DIR"] == f"/run/user/{os.getuid()}" + + +def test_systemd_render_unit_extracts_cd_prefix(tmp_path: Path) -> None: + from bench_cli.managers.process_manager import ProcessDefinition + + mgr = _make_systemd_manager(tmp_path) + pd = ProcessDefinition( + name="web", + command="cd /sites && /env/bin/python -m frappe.utils.bench_helper frappe serve", + log_file=tmp_path / "logs" / "web.log", + ) + unit = mgr._render_unit(pd) + assert "WorkingDirectory=/sites" in unit + assert "ExecStart=/env/bin/python" in unit + assert "cd /sites" not in unit + + +def test_systemd_render_unit_extracts_env_vars(tmp_path: Path) -> None: + from bench_cli.managers.process_manager import ProcessDefinition + + mgr = _make_systemd_manager(tmp_path) + pd = ProcessDefinition( + name="admin", + command="PYTHONPATH=/cli FOO=bar /env/bin/python -m admin.backend.server", + log_file=tmp_path / "logs" / "admin.log", + ) + unit = mgr._render_unit(pd) + assert "Environment=PYTHONPATH=/cli" in unit + assert "Environment=FOO=bar" in unit + assert "ExecStart=/env/bin/python" in unit + + +def test_systemd_render_unit_no_user_directive(tmp_path: Path) -> None: + from bench_cli.managers.process_manager import ProcessDefinition + + mgr = _make_systemd_manager(tmp_path) + pd = ProcessDefinition( + name="web", + command="/env/bin/python serve", + log_file=tmp_path / "logs" / "web.log", + ) + unit = mgr._render_unit(pd) + assert "User=" not in unit + + +def test_systemd_render_unit_part_of_target(tmp_path: Path) -> None: + from bench_cli.managers.process_manager import ProcessDefinition + + mgr = _make_systemd_manager(tmp_path) + pd = ProcessDefinition(name="web", command="/env/bin/python serve", log_file=tmp_path / "logs" / "web.log") + unit = mgr._render_unit(pd) + assert f"PartOf={mgr._target_name()}" in unit + + +def test_systemd_render_target_wanted_by_default(tmp_path: Path) -> None: + mgr = _make_systemd_manager(tmp_path) + target = mgr._render_target([]) + assert "WantedBy=default.target" in target + + +def test_systemd_generate_config_writes_unit_files(tmp_path: Path) -> None: + from bench_cli.managers.process_manager import ProcessDefinition + + mgr = _make_systemd_manager(tmp_path) + mgr.systemd_conf_dir.mkdir(parents=True, exist_ok=True) + fake_defs = [ProcessDefinition("web", "/env/bin/python serve", tmp_path / "logs" / "web.log")] + with patch("bench_cli.managers.admin_env_manager.AdminEnvManager"): + with patch.object(mgr, "_prod_process_definitions", return_value=fake_defs): + mgr.generate_config() + assert (mgr.systemd_conf_dir / "test-bench-web.service").exists() + assert (mgr.systemd_conf_dir / "test-bench.target").exists()