Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ A zero-dependency CLI for managing [Frappe](https://frappeframework.com) environ
## Requirements

**Ubuntu 22.04+** — Python 3.11+, `sudo` access
**Alpine 3.20+** — uses apk + OpenRC; `install.sh` bootstraps everything (production via `process_manager = "openrc"`)
**macOS** — Python 3.11+, [Homebrew](https://brew.sh) (dev only)

## Install
Expand All @@ -29,6 +30,12 @@ A zero-dependency CLI for managing [Frappe](https://frappeframework.com) environ
curl -fsSL https://raw.githubusercontent.com/frappe/bench-cli/main/install.sh | bash
```

On bare Alpine (no curl/bash preinstalled), bootstrap with busybox instead:

```sh
wget -qO- https://raw.githubusercontent.com/frappe/bench-cli/main/install.sh | sh
```

Clones to `~/bench-cli` and adds `bench` to `PATH`. Or manually:

```bash
Expand Down
26 changes: 22 additions & 4 deletions bench_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,19 @@ def _setup_volume(self) -> None:

VolumeSetupCommand(self.bench.config.volume, self.bench.path).run()

# Build/runtime deps for compiling frappe's Python and Node wheels.
# Alpine (musl) ships no manylinux wheels, so the full header set is needed;
# bash and tzdata are runtime deps frappe assumes are present.
_ALPINE_BUILD_PACKAGES = (
"build-base", "pkgconf", "mariadb-dev", "git", "bash", "tzdata",
"linux-headers", "libffi-dev", "openssl-dev", "libxml2-dev",
"libxslt-dev", "jpeg-dev", "zlib-dev", "freetype-dev", "tiff-dev",
"lcms2-dev", "openjpeg-dev",
)

def _install_system_packages(self) -> None:
from bench_cli.managers.mariadb_manager import MariaDBManager
from bench_cli.platform import get_package_manager, is_linux
from bench_cli.platform import get_package_manager, is_alpine, is_linux

pkg = get_package_manager()
if is_linux():
Expand All @@ -212,8 +222,9 @@ def _install_system_packages(self) -> None:
mariadb_manager.install()
mariadb_manager.start()
RedisManager(self.bench.config.redis, self.bench).install()
if is_linux():
pkg = get_package_manager()
if is_alpine():
pkg.install(*self._ALPINE_BUILD_PACKAGES)
elif is_linux():
pkg.install("build-essential", "pkg-config", "libmariadb-dev", "git")
PythonEnvManager(self.bench).ensure_python()

Expand All @@ -233,7 +244,14 @@ def _write_common_config_for_production(self, production: bool) -> None:
common_config_path.write_text(json.dumps(existing, indent=2))

def _setup_process_manager(self) -> None:
if self.bench.config.production.process_manager == "systemd":
if self.bench.config.production.process_manager == "openrc":
from bench_cli.managers.openrc_process_manager import OpenRCProcessManager

mgr = OpenRCProcessManager(self.bench)
mgr.install_config()
mgr.reload()
# openrc init scripts live inside config/ — _remove_bench_dirs handles it
elif self.bench.config.production.process_manager == "systemd":
from bench_cli.managers.systemd_process_manager import SystemdProcessManager

mgr = SystemdProcessManager(self.bench)
Expand Down
12 changes: 11 additions & 1 deletion bench_cli/commands/setup/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ def run(self) -> None:
self._require_production_enabled()
self._require_linux()
self._write_dns_multitenancy()
if self.bench.config.production.process_manager == "systemd":
if self.bench.config.production.process_manager == "openrc":
self._setup_openrc()
elif self.bench.config.production.process_manager == "systemd":
self._setup_systemd()
else:
self._setup_supervisor()
Expand Down Expand Up @@ -78,6 +80,14 @@ def _setup_systemd(self) -> None:
mgr.install_config()
mgr.reload()

def _setup_openrc(self) -> None:
from bench_cli.managers.openrc_process_manager import OpenRCProcessManager

mgr = OpenRCProcessManager(self.bench)
mgr.generate_config()
mgr.install_config()
mgr.reload()

def _setup_nginx(self) -> None:
from bench_cli.commands.setup.nginx import SetupNginxCommand

Expand Down
16 changes: 8 additions & 8 deletions bench_cli/commands/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ def run(self) -> None:
self._row("Mode", "development (Procfile)")
self._print_processes_dev()
else:
mode = "systemd (--user)" if prod.process_manager == "systemd" else "supervisor (bench-local)"
mode = {
"systemd": "systemd (--user)",
"openrc": "openrc",
"supervisor": "supervisor (bench-local)",
}.get(prod.process_manager, prod.process_manager)
self._row("Mode", f"production [{mode}]")
self._print_processes_prod()

Expand Down Expand Up @@ -113,13 +117,9 @@ def _print_zfs(self) -> None:
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")
from bench_cli.platform import service_running

return _ok("active") if service_running(service) else _dim("inactive")

def _section(self, title: str) -> None:
print(f"\n\033[1m{title}\033[0m")
Expand Down
4 changes: 3 additions & 1 deletion bench_cli/config/bench_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ def _parse_production(data: dict | None) -> ProductionConfig:

@staticmethod
def _parse_nginx(data: dict) -> NginxConfig:
config_dir = data.get("config_dir", "/etc/nginx/conf.d")
from bench_cli.platform import default_nginx_config_dir

config_dir = data.get("config_dir") or default_nginx_config_dir()
return NginxConfig(
http_port=data.get("http_port", 80),
https_port=data.get("https_port", 443),
Expand Down
4 changes: 3 additions & 1 deletion bench_cli/config/nginx_config.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from dataclasses import dataclass, field
from pathlib import Path

from bench_cli.platform import default_nginx_config_dir


@dataclass
class NginxConfig:
http_port: int = 80
https_port: int = 443
config_dir: Path = field(default_factory=lambda: Path("/etc/nginx/conf.d"))
config_dir: Path = field(default_factory=default_nginx_config_dir)
worker_processes: str = "auto"
client_max_body_size: str = "50m"
4 changes: 2 additions & 2 deletions bench_cli/config/production_config.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from dataclasses import dataclass

VALID_PROCESS_MANAGERS = ("none", "supervisor", "systemd")
VALID_PROCESS_MANAGERS = ("none", "supervisor", "systemd", "openrc")


@dataclass
class ProductionConfig:
process_manager: str = "none" # none | supervisor | systemd
process_manager: str = "none" # none | supervisor | systemd | openrc
nginx: bool = False

@property
Expand Down
41 changes: 34 additions & 7 deletions bench_cli/managers/mariadb_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,19 @@
from pathlib import Path

from bench_cli.config.mariadb_config import MariaDBConfig
from bench_cli.platform import get_package_manager, is_macos
from bench_cli.platform import (
_privileged,
get_package_manager,
is_alpine,
is_macos,
service_command,
service_enable_command,
)
from bench_cli.utils import run_command

_MACOS_SOCKET_CANDIDATES = ["/tmp/mysql.sock", "/usr/local/var/mysql/mysql.sock"]
_LINUX_SOCKET_CANDIDATES = ["/var/run/mysqld/mysqld.sock", "/run/mysqld/mysqld.sock"]
_ALPINE_DATA_DIR = Path("/var/lib/mysql")


class MariaDBManager:
Expand All @@ -18,23 +26,42 @@ def is_installed(self) -> bool:
return bool(shutil.which("mysqld") or shutil.which("mariadbd"))

def install(self) -> None:
if self.is_installed():
if not self.is_installed():
get_package_manager().install(*self._packages())
if is_alpine():
# Idempotent: Alpine's mariadb package ships an empty data dir and does
# not enable the service — initialise and enable on every init.
self._initialize_data_dir()
run_command(service_enable_command("mariadb"))

def _packages(self) -> list[str]:
if is_macos():
return [self._brew_package()]
if is_alpine():
return ["mariadb", "mariadb-client"]
return [self._apt_package()]

def _initialize_data_dir(self) -> None:
if (_ALPINE_DATA_DIR / "mysql").is_dir():
return
package_manager = get_package_manager()
package = self._brew_package() if is_macos() else self._apt_package()
package_manager.install(package)
run_command(_privileged([
"mariadb-install-db",
"--user=mysql",
f"--datadir={_ALPINE_DATA_DIR}",
"--skip-test-db",
]))

def start(self) -> None:
if is_macos():
run_command(["brew", "services", "start", self._brew_package()])
else:
run_command(["sudo", "systemctl", "start", "mariadb"])
run_command(service_command("start", "mariadb"))

def stop(self) -> None:
if is_macos():
run_command(["brew", "services", "stop", self._brew_package()])
else:
run_command(["sudo", "systemctl", "stop", "mariadb"])
run_command(service_command("stop", "mariadb"))

def _brew_package(self) -> str:
if self.config.version:
Expand Down
38 changes: 31 additions & 7 deletions bench_cli/managers/nginx_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@
from pathlib import Path
from typing import TYPE_CHECKING

from bench_cli.platform import get_package_manager, is_linux
from bench_cli.platform import (
_privileged,
get_package_manager,
is_alpine,
is_linux,
service_command,
service_enable_command,
service_running,
)
from bench_cli.utils import run_command

if TYPE_CHECKING:
Expand Down Expand Up @@ -279,15 +287,31 @@ def install_config(self) -> None:
source_path = self.bench.config_path / "nginx" / "include.conf"

if symlink_path.exists() or symlink_path.is_symlink():
run_command(["sudo", "unlink", str(symlink_path)])
run_command(["sudo", "ln", "-s", str(source_path), str(symlink_path)])
run_command(_privileged(["unlink", str(symlink_path)]))
run_command(_privileged(["ln", "-s", str(source_path), str(symlink_path)]))

if is_alpine():
# Alpine ships a catch-all default_server on both IPv4 and IPv6 (in the
# same http.d dir) that exists to "prevent access to any other
# virtualhost" — exactly what shadows the bench site over IPv6. Remove
# it so the bench server block is the sole listener on :80.
default_conf = nginx_dir / "default.conf"
if default_conf.exists():
run_command(_privileged(["rm", "-f", str(default_conf)]))

def reload(self) -> None:
run_command(["sudo", "nginx", "-t"])
if is_linux():
run_command(["sudo", "systemctl", "reload", "nginx"])
else:
run_command(_privileged(["nginx", "-t"]))
if not is_linux():
run_command(["nginx", "-s", "reload"])
return
if is_alpine():
# Alpine doesn't auto-start nginx after install — bring it up the
# first time, then reload in place on subsequent runs.
run_command(service_enable_command("nginx"))
action = "reload" if service_running("nginx") else "start"
run_command(service_command(action, "nginx"))
return
run_command(service_command("reload", "nginx"))

def cert_path(self, site: "SiteConfig") -> Path:
return Path("/etc/letsencrypt/live") / site.name / "fullchain.pem"
Expand Down
Loading
Loading