refactor: replace install.sh with Python install.py
This commit is contained in:
+263
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# install.py — install nuntius as a systemd service.
|
||||
#
|
||||
# nuntius ships as a single static binary. Build it on your workstation, upload
|
||||
# the binary, the systemd unit, and the .env template to the server, then run
|
||||
# this script there as root from that directory:
|
||||
#
|
||||
# scp bin/nuntius nuntius.service .env.example user@host:/tmp/nuntius/
|
||||
# ssh user@host
|
||||
# cd /tmp/nuntius && sudo python3 install.py
|
||||
#
|
||||
# It expects ./nuntius and ./nuntius.service (and optionally ./.env.example) in
|
||||
# the current working directory. Idempotent: re-running is safe — it skips the
|
||||
# user, config, and .env when they already exist, and never starts the service.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import NoReturn
|
||||
|
||||
NUNTIUS_USER = "nuntius"
|
||||
BIN_DEST = "/usr/local/bin/nuntius"
|
||||
CONFIG_DIR = "/etc/nuntius"
|
||||
CONFIG_FILE = "/etc/nuntius/config.toml"
|
||||
ENV_FILE = "/etc/nuntius/.env"
|
||||
DATA_DIR = "/var/lib/nuntius"
|
||||
SERVICE_DEST = "/etc/systemd/system/nuntius.service"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coloured logging
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ColourFormatter(logging.Formatter):
|
||||
"""Prefix each log line with a coloured level marker, mimicking the bash script."""
|
||||
|
||||
_COLOURS: dict[int, str] = {
|
||||
logging.INFO: "\033[1;34m", # blue
|
||||
logging.WARNING: "\033[1;33m", # yellow
|
||||
logging.ERROR: "\033[1;31m", # red
|
||||
}
|
||||
_LABELS: dict[int, str] = {
|
||||
logging.INFO: "==>",
|
||||
logging.WARNING: "WARN:",
|
||||
logging.ERROR: "ERROR:",
|
||||
}
|
||||
_RESET = "\033[0m"
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
colour = self._COLOURS.get(record.levelno, "")
|
||||
label = self._LABELS.get(record.levelno, "")
|
||||
if colour and label:
|
||||
return f"{colour}{label}{self._RESET} {record.getMessage()}"
|
||||
return record.getMessage()
|
||||
|
||||
|
||||
def _setup_logging() -> None:
|
||||
"""Configure the root logger with coloured console output."""
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO)
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(_ColourFormatter())
|
||||
# Remove any handlers added by basicConfig or other imports.
|
||||
logger.handlers.clear()
|
||||
logger.addHandler(handler)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command via subprocess and check for failure (unless told otherwise)."""
|
||||
return subprocess.run(cmd, check=True, text=True, **kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def require_root() -> None:
|
||||
"""Exit if not running as root."""
|
||||
if os.geteuid() != 0:
|
||||
logging.error("Run as root: sudo python3 install.py")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def require_files() -> None:
|
||||
"""Verify that the binary and the systemd unit are present in CWD."""
|
||||
if not os.path.isfile("./nuntius"):
|
||||
logging.error(
|
||||
"./nuntius binary not found — copy it here first (e.g. rsync bin/nuntius)."
|
||||
)
|
||||
sys.exit(1)
|
||||
if not os.path.isfile("./nuntius.service"):
|
||||
logging.error(
|
||||
"./nuntius.service not found — paste the unit from docs/deployment.md (Option A)."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def create_user() -> None:
|
||||
"""Create the system user if it does not already exist."""
|
||||
try:
|
||||
_run(["id", NUNTIUS_USER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
logging.info("User %s already exists.", NUNTIUS_USER)
|
||||
except subprocess.CalledProcessError:
|
||||
logging.info("Creating system user %s…", NUNTIUS_USER)
|
||||
_run(
|
||||
[
|
||||
"useradd",
|
||||
"--system",
|
||||
"--shell",
|
||||
"/usr/sbin/nologin",
|
||||
"--home-dir",
|
||||
DATA_DIR,
|
||||
"--user-group",
|
||||
NUNTIUS_USER,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def create_data_dir() -> None:
|
||||
"""Ensure the data directory exists with correct ownership and permissions."""
|
||||
logging.info("Setting up %s…", DATA_DIR)
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
shutil.chown(DATA_DIR, user=NUNTIUS_USER, group=NUNTIUS_USER)
|
||||
os.chmod(DATA_DIR, 0o750)
|
||||
|
||||
|
||||
def install_binary() -> None:
|
||||
"""Copy the nuntius binary to /usr/local/bin with mode 0755."""
|
||||
logging.info("Installing binary to %s…", BIN_DEST)
|
||||
shutil.copy2("./nuntius", BIN_DEST)
|
||||
os.chmod(BIN_DEST, 0o755)
|
||||
|
||||
|
||||
def install_service() -> None:
|
||||
"""Copy the systemd unit file with mode 0644."""
|
||||
logging.info("Installing systemd unit %s…", SERVICE_DEST)
|
||||
shutil.copy2("./nuntius.service", SERVICE_DEST)
|
||||
os.chmod(SERVICE_DEST, 0o644)
|
||||
|
||||
|
||||
def generate_config() -> None:
|
||||
"""Auto-generate the TOML config by running nuntius briefly if config is absent."""
|
||||
if os.path.isfile(CONFIG_FILE):
|
||||
logging.info("Config %s already exists; leaving it untouched.", CONFIG_FILE)
|
||||
return
|
||||
|
||||
logging.info("Generating %s…", CONFIG_FILE)
|
||||
os.makedirs(CONFIG_DIR, exist_ok=True)
|
||||
|
||||
# nuntius writes the template config on first start; run it briefly, then
|
||||
# let `timeout` send SIGTERM (graceful shutdown). The write happens at
|
||||
# startup, before the timeout elapses.
|
||||
try:
|
||||
_run(["timeout", "-k", "5s", "4s", BIN_DEST], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except subprocess.CalledProcessError:
|
||||
# timeout exits 124 on SIGTERM, 137 on SIGKILL — both are expected.
|
||||
pass
|
||||
|
||||
if not os.path.isfile(CONFIG_FILE):
|
||||
logging.warning("nuntius did not create %s; check the uploaded binary.", CONFIG_FILE)
|
||||
|
||||
# Fix ownership in case nuntius wrote files as root.
|
||||
_run(["chown", "-R", f"{NUNTIUS_USER}:{NUNTIUS_USER}", DATA_DIR])
|
||||
|
||||
|
||||
def install_env() -> None:
|
||||
"""Copy .env.example to /etc/nuntius/.env if it does not already exist."""
|
||||
if os.path.isfile(ENV_FILE):
|
||||
logging.info("%s already exists; leaving it untouched.", ENV_FILE)
|
||||
elif os.path.isfile("./.env.example"):
|
||||
logging.info("Creating %s from .env.example (edit it!)…", ENV_FILE)
|
||||
shutil.copy2("./.env.example", ENV_FILE)
|
||||
os.chmod(ENV_FILE, 0o600)
|
||||
_run(["chown", f"root:{NUNTIUS_USER}", ENV_FILE])
|
||||
else:
|
||||
logging.warning(
|
||||
".env.example not found; skipping %s. Create it before starting.", ENV_FILE
|
||||
)
|
||||
|
||||
|
||||
def enable_service() -> None:
|
||||
"""Reload systemd and enable the nuntius service (does not start it)."""
|
||||
logging.info("Reloading systemd and enabling nuntius…")
|
||||
_run(["systemctl", "daemon-reload"])
|
||||
_run(["systemctl", "enable", "nuntius.service"])
|
||||
|
||||
|
||||
def final_notes() -> None:
|
||||
"""Print post-install instructions."""
|
||||
print(
|
||||
f"""
|
||||
============================================================
|
||||
✓ nuntius installed and enabled, but NOT started.
|
||||
|
||||
Next manual steps:
|
||||
1. Set the SMTP password:
|
||||
sudo $EDITOR {ENV_FILE}
|
||||
2. Edit the auto-generated config (smtp.host, smtp.user, allowed_origins):
|
||||
sudo $EDITOR {CONFIG_FILE}
|
||||
3. Start the service:
|
||||
sudo systemctl start nuntius
|
||||
sudo journalctl -u nuntius -f
|
||||
4. Add the nginx location block (see README.md), then:
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
============================================================
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="install.py",
|
||||
description="install nuntius as a systemd service.",
|
||||
epilog=(
|
||||
"Idempotent: re-running is safe. The service is enabled but never started."
|
||||
),
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> NoReturn:
|
||||
"""Parse args and run all install steps in order."""
|
||||
_setup_logging()
|
||||
parser = _build_parser()
|
||||
parser.parse_args(argv)
|
||||
|
||||
require_root()
|
||||
require_files()
|
||||
create_user()
|
||||
create_data_dir()
|
||||
install_binary()
|
||||
install_service()
|
||||
generate_config()
|
||||
install_env()
|
||||
enable_service()
|
||||
final_notes()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user