Skip to content
Merged
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
101 changes: 101 additions & 0 deletions deploy/install-units.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
#
# Write the systemd units and reload. The single source of truth for how this
# bot runs — both setup.sh (first install) and update.sh (every update) call it,
# so the running configuration always matches what is committed.
#
# Idempotent and cheap: two small files plus a daemon-reload. It does NOT start
# or restart anything; the caller decides that.
#
# Usage:
# bash deploy/install-units.sh
#
set -euo pipefail

APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VENV_DIR="$APP_DIR/.venv"
SERVICE_NAME="loopify-bot"
UPDATER_NAME="loopify-ytdlp-update"
# Whoever owns the checkout is who the bot runs as.
APP_USER="$(stat -c '%U' "$APP_DIR")"

echo "==> Installing systemd units (user: $APP_USER, dir: $APP_DIR)"

sudo tee "/etc/systemd/system/${SERVICE_NAME}.service" >/dev/null <<UNIT
[Unit]
Description=LoopifyBot Discord Music Bot
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=$APP_USER
WorkingDirectory=$APP_DIR
# No EnvironmentFile on purpose. The bot reads .env with python-dotenv, and
# having systemd parse the same file too means two parsers with different rules
# for quoting and inline comments — they disagree silently, and the result looks
# like a bad credential rather than a parsing problem.
# Keep caches inside the (writable) app dir since HOME is read-only below.
Environment=XDG_CACHE_HOME=$APP_DIR/.cache
Environment=DENO_DIR=$APP_DIR/.cache/deno
ExecStart=$VENV_DIR/bin/python $APP_DIR/main.py
Restart=on-failure
RestartSec=5
# Give the bot a moment to shut down cleanly.
KillSignal=SIGINT
TimeoutStopSec=15
# ── Sandboxing / hardening ──
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=$APP_DIR
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
# Cap resources so a runaway can't take down a small box.
MemoryMax=768M
TasksMax=256

[Install]
WantedBy=multi-user.target
UNIT

# yt-dlp is the one dependency that must NOT be pinned: YouTube changes its
# player constantly and a stale build stops resolving videos within weeks.
chmod +x "$APP_DIR/deploy/update-ytdlp.sh"

sudo tee "/etc/systemd/system/${UPDATER_NAME}.service" >/dev/null <<UNIT
[Unit]
Description=Refresh yt-dlp for LoopifyBot
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
# Runs as root to restart the service; drops to $APP_USER for the pip install.
ExecStart=$APP_DIR/deploy/update-ytdlp.sh
UNIT

sudo tee "/etc/systemd/system/${UPDATER_NAME}.timer" >/dev/null <<UNIT
[Unit]
Description=Daily yt-dlp refresh for LoopifyBot

[Timer]
OnCalendar=daily
# Spread the load rather than hitting PyPI at midnight with everyone else.
RandomizedDelaySec=2h
# Catch up after downtime — important on a machine that isn't on 24/7.
Persistent=true

[Install]
WantedBy=timers.target
UNIT

sudo systemctl daemon-reload
sudo systemctl enable "$SERVICE_NAME"
sudo systemctl enable --now "${UPDATER_NAME}.timer"
86 changes: 4 additions & 82 deletions deploy/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,88 +63,10 @@ else
echo " Copy .env.example to .env and fill in DISCORD_TOKEN before starting."
fi

# ── 4. systemd service ────────────────────────────────────────────────
echo "==> Installing systemd service '$SERVICE_NAME'..."
sudo tee "/etc/systemd/system/${SERVICE_NAME}.service" >/dev/null <<UNIT
[Unit]
Description=LoopifyBot Discord Music Bot
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=$APP_USER
WorkingDirectory=$APP_DIR
# No EnvironmentFile on purpose. The bot reads .env with python-dotenv, and
# having systemd parse the same file too means two parsers with different rules
# for quoting and inline comments — they disagree silently, and the result looks
# like a bad credential rather than a parsing problem.
# Keep caches inside the (writable) app dir since HOME is read-only below.
Environment=XDG_CACHE_HOME=$APP_DIR/.cache
Environment=DENO_DIR=$APP_DIR/.cache/deno
ExecStart=$VENV_DIR/bin/python $APP_DIR/main.py
Restart=on-failure
RestartSec=5
# Give the bot a moment to shut down cleanly.
KillSignal=SIGINT
TimeoutStopSec=15
# ── Sandboxing / hardening ──
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=$APP_DIR
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
# Cap resources so a runaway can't take down a 1 GB box.
MemoryMax=768M
TasksMax=256

[Install]
WantedBy=multi-user.target
UNIT

# ── 5. yt-dlp auto-update timer ───────────────────────────────────────
# yt-dlp is the one dependency that must NOT be pinned: YouTube changes its
# player constantly and a stale build stops resolving videos within weeks.
echo "==> Installing yt-dlp auto-update timer..."
chmod +x "$APP_DIR/deploy/update-ytdlp.sh"

sudo tee "/etc/systemd/system/${SERVICE_NAME%-bot}-ytdlp-update.service" >/dev/null <<UNIT
[Unit]
Description=Refresh yt-dlp for LoopifyBot
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
# Runs as root to restart the service; drops to $APP_USER for the pip install.
ExecStart=$APP_DIR/deploy/update-ytdlp.sh
UNIT

sudo tee "/etc/systemd/system/${SERVICE_NAME%-bot}-ytdlp-update.timer" >/dev/null <<UNIT
[Unit]
Description=Daily yt-dlp refresh for LoopifyBot

[Timer]
OnCalendar=daily
# Spread the load rather than hitting PyPI at midnight with everyone else.
RandomizedDelaySec=2h
# Catch up after downtime — important on a machine that isn't on 24/7.
Persistent=true

[Install]
WantedBy=timers.target
UNIT

sudo systemctl daemon-reload
sudo systemctl enable "$SERVICE_NAME"
sudo systemctl enable --now "${SERVICE_NAME%-bot}-ytdlp-update.timer"
# ── 4. systemd units ─────────────────────────────────────────
# The unit definitions live in install-units.sh, which update.sh also runs, so
# the deployed configuration never drifts from what is committed.
bash "$APP_DIR/deploy/install-units.sh"

echo ""
echo "==> Done. Manage the bot with:"
Expand Down
5 changes: 5 additions & 0 deletions deploy/update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ else
"$VENV_DIR/bin/pip" install --quiet --upgrade -r requirements.txt
fi

# Reinstall the units every time. A pull can change how the bot is *run* — its
# sandboxing, resource caps, the updater schedule — and restarting alone would
# silently keep the old configuration while the repo says otherwise.
bash "$APP_DIR/deploy/install-units.sh"

echo "==> Restarting $SERVICE_NAME..."
sudo systemctl restart "$SERVICE_NAME"

Expand Down
45 changes: 34 additions & 11 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,17 +148,40 @@ def test_dotenv_is_anchored_to_the_project_root():
assert os.path.isfile(os.path.join(config.PROJECT_ROOT, "config.py"))


def test_the_systemd_unit_does_not_also_parse_env():
def _deploy_scripts() -> list:
import glob
import os
found = glob.glob(os.path.join(config.PROJECT_ROOT, "deploy", "*.sh"))
assert found, "no deploy scripts found — this guard would pass vacuously"
return found


def test_no_systemd_unit_also_parses_env():
"""
Two parsers over one file disagree on quoting and fail silently. Only
python-dotenv reads .env; the unit must not declare EnvironmentFile.
python-dotenv reads .env; no unit may declare EnvironmentFile.

Scans every deploy script rather than one by name, so moving the unit
definition between files cannot quietly turn this guard off.
"""
import os
setup = os.path.join(config.PROJECT_ROOT, "deploy", "setup.sh")
with open(setup, encoding="utf-8") as handle:
body = handle.read()
active = [
line for line in body.splitlines()
if "EnvironmentFile" in line and not line.lstrip().startswith("#")
]
assert active == []
offenders = []
for path in _deploy_scripts():
with open(path, encoding="utf-8") as handle:
for number, line in enumerate(handle, 1):
if "EnvironmentFile" in line and not line.lstrip().startswith("#"):
offenders.append(f"{path}:{number}")
assert offenders == []


def test_the_bot_unit_is_defined_exactly_once():
"""
setup.sh and update.sh both install units. If either grew its own copy of
the definition they would drift, and the deployed config would depend on
which script ran last.
"""
definers = []
for path in _deploy_scripts():
with open(path, encoding="utf-8") as handle:
if "Description=LoopifyBot Discord Music Bot" in handle.read():
definers.append(path)
assert len(definers) == 1, f"the service unit is defined in {len(definers)} places: {definers}"
Loading