Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
9c89746
feat(platform): Alpine detection, apk package manager, OpenRC service…
the-bokya Jun 8, 2026
a8c5a88
feat(managers): Alpine packages and OpenRC service calls
the-bokya Jun 8, 2026
f073686
feat(production): OpenRC process manager for Alpine
the-bokya Jun 8, 2026
5bdc423
feat(init): Alpine build deps and POSIX/apk-bootstrapping install.sh
the-bokya Jun 8, 2026
d957d4f
docs(readme): document Alpine support and busybox bootstrap
the-bokya Jun 8, 2026
436c46c
fix(nginx): remove Alpine's catch-all default.conf during setup
the-bokya Jun 8, 2026
e03f954
fix(status): report openrc mode and check services without systemctl
the-bokya Jun 8, 2026
8a711e8
Merge upstream/main into alpine-support
the-bokya Jun 21, 2026
971b165
feat(platform): Alpine detection, apk package manager, OpenRC service…
the-bokya Jun 21, 2026
08d42a5
feat(config): accept openrc process manager; distro-aware nginx confi…
the-bokya Jun 21, 2026
58af882
feat(production): OpenRC process manager at parity with systemd/super…
the-bokya Jun 21, 2026
11a3787
feat(mariadb): install, init and service control on Alpine
the-bokya Jun 21, 2026
8c91d3d
feat(nginx): reload, privileges and default-server handling on Alpine
the-bokya Jun 21, 2026
c98dc77
feat(alpine): node, certbot reload hook and volume mariadb control
the-bokya Jun 21, 2026
7f850b7
feat(init): Alpine build deps, OpenRC setup, shared mariadb on new be…
the-bokya Jun 21, 2026
c28e2a5
feat(production): openrc in setup/remove production and status
the-bokya Jun 21, 2026
1990eba
feat(admin): OpenRC support in the backend
the-bokya Jun 21, 2026
17bab62
feat(install)+docs: POSIX installer with Alpine bootstrap; document O…
the-bokya Jun 21, 2026
84f4ab7
fix(platform): query OpenRC service status with privilege
the-bokya Jun 21, 2026
24432b3
refactor(admin): clearer pid guard in OpenRC reader
the-bokya Jun 21, 2026
099e8ca
fix(nginx): scope Alpine default-vhost removal to Alpine
the-bokya Jun 21, 2026
5be9dce
feat(platform): native_process_manager() helper
the-bokya Jun 21, 2026
a3385f1
fix(admin): OpenRC parity in the settings backend
the-bokya Jun 21, 2026
9cb8d35
feat(admin): surface native process manager in setup config & status
the-bokya Jun 21, 2026
b3d654d
feat(ui): offer OpenRC as the process manager on Alpine
the-bokya Jun 21, 2026
739986f
feat(mariadb): dedicated instances on Alpine/OpenRC
the-bokya Jun 21, 2026
467af21
docs: reflect OpenRC parity (dedicated MariaDB + process manager UI)
the-bokya Jun 21, 2026
3392880
fix(restart): mention OpenRC in the dev-mode restart message
the-bokya Jun 21, 2026
50f2437
fix(init): install python3-dev so C-extension wheels build
the-bokya Jun 21, 2026
32904cc
fix(volume): install ZFS with Alpine packages, fail clearly without a…
the-bokya Jun 21, 2026
bdf3fef
Merge remote-tracking branch 'upstream/main' into alpine-support
the-bokya Jun 21, 2026
5d17eae
fix(test): make shutil.which mocks accept **kw for platform.which()
the-bokya Jun 22, 2026
a8bef69
fix(install): fail clearly on Alpine when sudo is missing
the-bokya Jun 22, 2026
fb00896
fix(setup): reject systemd process manager on Alpine
the-bokya Jun 22, 2026
757059b
fix(admin): coerce systemd to openrc on Alpine in settings API
the-bokya Jun 22, 2026
fb673ef
fix(openrc): run supervised bench processes as the bench user
the-bokya Jun 22, 2026
0a8ef84
fix(volume): sbin-aware ZFS detection, let module install errors surface
the-bokya Jun 22, 2026
72572f8
fix(install): drop non-POSIX `local` for /bin/sh compatibility
the-bokya Jun 22, 2026
620f936
docs(production): reflect per-bench MariaDB OpenRC services on Alpine
the-bokya Jun 22, 2026
f883a5b
Merge branch 'main' into alpine-support
the-bokya Jun 22, 2026
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
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ A zero-dependency CLI for managing [Frappe](https://frappeframework.com) environ
## Requirements

**Ubuntu 22.04+** — Python 3.11+, a user with `sudo` access
**Alpine 3.20+** — apk + OpenRC; `install.sh` bootstraps everything. Production runs under OpenRC (`process_manager = "openrc"`) instead of systemd
**macOS** — Python 3.11+, [Homebrew](https://brew.sh) (dev only — no `sudo` setup)

## Install
Expand All @@ -30,6 +31,13 @@ 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 `wget` + `sh`
instead — the installer apk-installs git, curl, bash, sudo and the build deps itself:

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

This single command:

- Clones bench-cli to `~/bench-cli` and adds `bench` to your `PATH`
Expand Down Expand Up @@ -153,7 +161,7 @@ tls = false # server-wide HTTPS opt-in (Let's Encrypt); f

[production]
enabled = true # set by `bench setup production`
process_manager = "supervisor" # systemd | supervisor
process_manager = "supervisor" # systemd | supervisor | openrc
use_companion_manager = false # run scheduler/workers/socketio inside gunicorn

[gunicorn]
Expand Down Expand Up @@ -189,7 +197,7 @@ Each bench lives on a single dataset (`<pool>/<bench>`) holding both its files a
| `bench init -b <name>` | Install deps, create venv, clone framework, generate Procfile (needs `-b <name>` or run inside the bench dir) |
| `bench start` | Start all processes (web, workers, Redis, admin UI) |
| `bench stop` | Stop a running bench from another terminal |
| `bench restart` | Restart all processes — supervisor or systemd (production only) |
| `bench restart` | Restart all processes — supervisor, systemd, or OpenRC (production only) |
| `bench get-app <repo>` | Clone and install an app |
| `bench new-site <name>` | Create a site |
| `bench rename-site <old> <new>` | Rename a site (checks the hostname is free across all benches) |
Expand Down Expand Up @@ -240,7 +248,7 @@ That's the whole change — `bench hello` now works. Commands that take argument
```toml
[production]
enabled = true # set by `bench setup production`
process_manager = "supervisor" # systemd | supervisor
process_manager = "supervisor" # systemd | supervisor | openrc
use_companion_manager = false # run scheduler/workers/socketio inside gunicorn

[gunicorn]
Expand Down Expand Up @@ -269,6 +277,7 @@ bench remove production # tear down production, back to dev (keeps certs/
**Process managers:**
- **Supervisor** — runs a bench-owned `supervisord` instance, no root needed.
- **Systemd** — uses `systemctl --user` units; requires `loginctl enable-linger` once.
- **OpenRC** — the Alpine counterpart of systemd: one `supervise-daemon` init script per process under `/etc/init.d/`. Selected automatically on Alpine.
- **None** — development mode; use `bench start` / Procfile runner.

**Companion manager:**
Expand Down
28 changes: 21 additions & 7 deletions admin/backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,14 @@ def api_status():
return jsonify({"enabled": False, "error": str(exc)}), 503
if not initialized or not config.admin.password:
return jsonify(_wizard_status(bench_root))
from bench_cli.platform import native_process_manager

return jsonify(
{
"enabled": config.admin.enabled,
"name": config.name,
"production": config.production.enabled,
"native_process_manager": native_process_manager(),
"authenticated": bool(session.get("authenticated")),
}
)
Expand Down Expand Up @@ -236,11 +239,19 @@ def api_benches_new():
if not name or not _NAME_RE.match(name):
return jsonify({"error": "Bench name must contain only letters, numbers, '-' and '_'"}), 400

from bench_cli.config.production_config import VALID_PROCESS_MANAGERS
from bench_cli.platform import is_alpine

process_manager = (data.get("process_manager") or "").strip().lower()
if process_manager == "supervisord":
process_manager = "supervisor"
if process_manager not in ("systemd", "supervisor"):
return jsonify({"error": "Choose a process manager: systemd or supervisor."}), 400
if process_manager not in VALID_PROCESS_MANAGERS:
return jsonify({"error": f"Choose a process manager: {', '.join(VALID_PROCESS_MANAGERS)}."}), 400
if is_alpine() and process_manager == "systemd":
# Alpine has no systemd; the UI offers OpenRC there, but coerce any
# stale systemd request to OpenRC (the native Alpine manager) so a
# cached client can never deploy an unmanageable bench.
process_manager = "openrc"

admin_domain = (data.get("admin_domain") or "").strip()
if not admin_domain:
Expand Down Expand Up @@ -282,16 +293,19 @@ def api_benches_new():
from bench_cli.config.bench_config import BenchConfig
from bench_cli.core.bench import Bench
from bench_cli.managers.nginx_manager import NginxManager
from bench_cli.managers.supervisor_process_manager import SupervisorProcessManager
from bench_cli.managers.systemd_process_manager import SystemdProcessManager

bench = Bench(BenchConfig.from_file(new_dir / "bench.toml"), new_dir)
# Not deployed yet (production.enabled is false at this point), so
# pick the manager by the configured process_manager rather than
# via the factory, which gates on enabled.
pm = (SystemdProcessManager if bench.config.production.process_manager == "systemd"
else SupervisorProcessManager)
pm(bench).setup_admin()
configured_pm = bench.config.production.process_manager
if configured_pm == "systemd":
from bench_cli.managers.systemd_process_manager import SystemdProcessManager as PM
elif configured_pm == "openrc":
from bench_cli.managers.openrc_process_manager import OpenRCProcessManager as PM
else:
from bench_cli.managers.supervisor_process_manager import SupervisorProcessManager as PM
PM(bench).setup_admin()
nginx = NginxManager(bench)
nginx.generate_config()
nginx.install_config()
Expand Down
46 changes: 44 additions & 2 deletions admin/backend/readers/process_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,24 @@ def __init__(self, bench_root: Path) -> None:
def read_all(self) -> list[ProcessInfo]:
from bench_cli.config.bench_config import BenchConfig
from bench_cli.core.bench import Bench
from bench_cli.managers.supervisor_process_manager import SupervisorProcessManager
from bench_cli.managers.systemd_process_manager import SystemdProcessManager

# If the bench config file is not present there is no point in look at procs
config = BenchConfig.from_file(self._bench_root / "bench.toml")
bench = Bench(config, self._bench_root)

# Alpine: only OpenRC is present, so probe it directly (the systemd /
# supervisor probes would shell out to CLIs that aren't installed).
if config.production.process_manager == "openrc":
from bench_cli.managers.openrc_process_manager import OpenRCProcessManager

openrc = OpenRCProcessManager(bench)
if openrc.is_running() or openrc.admin_is_running():
return self._read_from_openrc(openrc)
return self._read_from_pids()

from bench_cli.managers.supervisor_process_manager import SupervisorProcessManager
from bench_cli.managers.systemd_process_manager import SystemdProcessManager

systemd = SystemdProcessManager(bench)
supervisor = SupervisorProcessManager(bench)
if systemd.is_running():
Expand All @@ -155,6 +167,36 @@ def read_all(self) -> list[ProcessInfo]:

return self._read_from_pids()

# ── OpenRC ─────────────────────────────────────────────────────────────────

def _read_from_openrc(self, openrc) -> list[ProcessInfo]:
from bench_cli.platform import service_running

infos: list[ProcessInfo] = []
for pd in openrc._all_definitions():
service = openrc._service_name(pd.name)
running = service_running(service)
pid = self._read_pidfile(Path(f"/run/{service}.pid"))
status = "running" if running else "stopped"
running_now = bool(running and pid)
if running_now and pid is not None:
cpu, rss, pss = _get_process_stats(pid)
uptime = _proc_uptime(pid)
else:
cpu = rss = pss = uptime = None
infos.append(ProcessInfo(
name=pd.name, status=status, pid=pid, uptime=uptime,
log_file=pd.log_file, cpu_percent=cpu, rss_mb=rss, pss_mb=pss,
))
return infos

@staticmethod
def _read_pidfile(pid_file: Path) -> int | None:
try:
return int(pid_file.read_text().strip())
except (ValueError, OSError):
return None

# ── Systemd ──────────────────────────────────────────────────────────────

def _read_from_systemd(self, systemd: "SystemdProcessManager") -> list[ProcessInfo]:
Expand Down
33 changes: 30 additions & 3 deletions admin/backend/views/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from bench_cli.config.worker_config import WorkerGroup
from bench_cli.managers.redis_manager import RedisManager
from bench_cli.managers.volume_manager import VolumeManager
from bench_cli.platform import is_linux
from bench_cli.platform import is_linux, native_process_manager

settings_bp = Blueprint("settings", __name__)

Expand Down Expand Up @@ -137,10 +137,19 @@ def _apply_production(self) -> str | None:
if not production:
return None
if "process_manager" in production:
from bench_cli.config.production_config import VALID_PROCESS_MANAGERS
from bench_cli.platform import is_alpine

process_manager = str(production["process_manager"])
if process_manager not in ("none", "supervisor", "systemd"):
return "process_manager must be none, supervisor, or systemd"
valid = ("none", *VALID_PROCESS_MANAGERS)
if process_manager not in valid:
return f"process_manager must be one of: {', '.join(valid)}"
pm = "" if process_manager == "none" else process_manager
if is_alpine() and pm == "systemd":
# Alpine has no systemd; coerce a stale systemd request to OpenRC
# (the native Alpine manager), matching the new-bench endpoint, so
# a cached client default can't break the deployment.
pm = "openrc"
self.config.production.process_manager = pm
Comment on lines 139 to 153
self.config.production.enabled = pm != ""
return None
Expand Down Expand Up @@ -181,6 +190,20 @@ def _restart_supervisor(manager, bench_name: str) -> tuple[bool, str | None]:
return (result.returncode == 0), (result.stderr or result.stdout if result.returncode != 0 else None)


def _restart_openrc(manager) -> tuple[bool, str | None]:
if not manager.is_running():
return False, None
# Configs were regenerated already; re-link any new services (e.g. an added
# worker group) before restarting the workload. The admin service is left
# running so the control plane stays reachable across the restart.
try:
manager.install_config()
manager.restart()
except Exception as error:
return False, str(error)
return True, None


def _restart_systemd(manager) -> tuple[bool, str | None]:
if not manager.is_running():
return False, None
Expand All @@ -195,6 +218,7 @@ def _restart_systemd(manager) -> tuple[bool, str | None]:

def _do_restart(bench_root: Path, config: BenchConfig) -> tuple[bool, str | None]:
from bench_cli.core.bench import Bench
from bench_cli.managers.openrc_process_manager import OpenRCProcessManager
from bench_cli.managers.process_manager import ProcessManagerFactory
from bench_cli.managers.supervisor_process_manager import SupervisorProcessManager
from bench_cli.managers.systemd_process_manager import SystemdProcessManager
Expand All @@ -205,6 +229,8 @@ def _do_restart(bench_root: Path, config: BenchConfig) -> tuple[bool, str | None
return _restart_supervisor(manager, config.name)
if isinstance(manager, SystemdProcessManager):
return _restart_systemd(manager)
if isinstance(manager, OpenRCProcessManager):
return _restart_openrc(manager)
return False, None


Expand All @@ -215,6 +241,7 @@ def _build_settings_response(config: BenchConfig) -> dict:
volume = config.volume
return {
"is_linux": is_linux(),
"native_process_manager": native_process_manager(),
"bench": {"name": config.name, "python": config.python_version, "http_port": config.http_port, "socketio_port": config.socketio_port, "default_branch": config.default_branch},
"mariadb": {
"host": config.mariadb.host,
Expand Down
3 changes: 2 additions & 1 deletion admin/backend/views/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,11 +256,12 @@ def generate():

def _read_defaults(bench_root: Path) -> dict:
from admin.backend.tasks.manager.task_reader import TaskReader
from bench_cli.platform import is_linux
from bench_cli.platform import is_linux, native_process_manager

result = {
"bench_name": bench_root.name,
"is_linux": is_linux(),
"native_process_manager": native_process_manager(),
**BenchTomlBuilder.DEFAULTS,
}
toml_path = bench_root / "bench.toml"
Expand Down
15 changes: 11 additions & 4 deletions admin/frontend/src/components/NewBenchDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import { ref, computed, watch } from 'vue'
import { Button, Dialog, ErrorMessage, FormControl } from 'frappe-ui'

const PM_LABELS = { systemd: 'Systemd', openrc: 'OpenRC', supervisor: 'Supervisor' }

const props = defineProps({ modelValue: Boolean })
const emit = defineEmits(['update:modelValue'])

Expand All @@ -11,6 +13,8 @@ const show = computed({
})

const name = ref('')
// The host's native production manager: 'openrc' on Alpine, 'systemd' elsewhere.
const nativeProcessManager = ref('systemd')
const processManager = ref('systemd')
const adminDomain = ref('')
const error = ref('')
Expand All @@ -23,10 +27,11 @@ const status = ref('')
// In that case we point the user at the CLI instead.
const isProduction = ref(null)

const processManagerOptions = [
{ value: 'systemd', label: 'Systemd', hint: 'Recommended' },
// Native manager is recommended; supervisor is the cross-platform alternative.
const processManagerOptions = computed(() => [
{ value: nativeProcessManager.value, label: PM_LABELS[nativeProcessManager.value] || nativeProcessManager.value, hint: 'Recommended' },
{ value: 'supervisor', label: 'Supervisor', hint: 'Alternative' },
]
])

async function loadMode() {
isProduction.value = null
Expand All @@ -35,6 +40,8 @@ async function loadMode() {
if (response.ok) {
const data = await response.json()
isProduction.value = data.production === true
nativeProcessManager.value = data.native_process_manager || 'systemd'
processManager.value = nativeProcessManager.value
} else {
isProduction.value = false
}
Expand All @@ -46,7 +53,7 @@ async function loadMode() {
watch(show, (open) => {
if (!open) return
name.value = ''
processManager.value = 'systemd'
processManager.value = nativeProcessManager.value
adminDomain.value = ''
error.value = ''
creating.value = false
Expand Down
13 changes: 9 additions & 4 deletions admin/frontend/src/components/SettingsModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const BASE_TABS = [
{ key: 'updates', label: 'Updates' },
]
const isLinux = ref(false)
// The host's native production manager: 'openrc' on Alpine, 'systemd' elsewhere.
const nativeProcessManager = ref('systemd')
const TABS = computed(() => {
let tabs = isLinux.value
? [...BASE_TABS, { key: 'volume', label: 'ZFS Volume' }]
Expand All @@ -53,11 +55,13 @@ const saving = ref(false)
const saveError = ref('')
const saveSuccess = ref('')

const PROCESS_MANAGER_OPTIONS = [
// Native manager (systemd/OpenRC) per host, plus the cross-platform supervisor.
const PM_LABELS = { systemd: 'Systemd', openrc: 'OpenRC', supervisor: 'Supervisor' }
const processManagerOptions = computed(() => [
{ label: 'None (development)', value: 'none' },
{ label: PM_LABELS[nativeProcessManager.value] || nativeProcessManager.value, value: nativeProcessManager.value },
{ label: 'Supervisor', value: 'supervisor' },
{ label: 'Systemd', value: 'systemd' },
]
])

const form = ref(null)

Expand All @@ -69,6 +73,7 @@ async function load() {
if (!res.ok) throw new Error(`${res.status}`)
const data = await res.json()
isLinux.value = data.is_linux === true
nativeProcessManager.value = data.native_process_manager || 'systemd'
if (Array.isArray(data.workers))
data.workers = data.workers.map(g => ({ queues: (g.queues || []).join(', '), count: g.count }))
form.value = data
Expand Down Expand Up @@ -344,7 +349,7 @@ watch(() => props.modelValue, (val) => {
<!-- Bench -->
<div v-else-if="activeTab === 'bench'" class="flex flex-col gap-4">
<h4 class="font-semibold text-ink-gray-8">Process Manager</h4>
<Select :options="PROCESS_MANAGER_OPTIONS" v-model="form.production.process_manager" class="w-64" />
<Select :options="processManagerOptions" v-model="form.production.process_manager" class="w-64" />
<div class="border-t border-outline-gray-1" />
<div class="grid grid-cols-2 gap-4">
<FormControl label="Name" :modelValue="form.bench.name" disabled />
Expand Down
Loading
Loading