diff --git a/.env.example b/.env.example index f2b6e09..19b534e 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,9 @@ +# Syntax: KEY=value, one per line. Values are taken literally to end of line, +# so do NOT add trailing comments after a value. Quote anything containing +# spaces. Lines starting with # are ignored. +# +# This file is read only by the bot (python-dotenv); systemd does not parse it. + # ── Discord ──────────────────────────────────────────────────────────── # Required. Bot token from https://discord.com/developers/applications DISCORD_TOKEN= diff --git a/config.py b/config.py index 746b78f..5891527 100644 --- a/config.py +++ b/config.py @@ -9,7 +9,16 @@ from dotenv import load_dotenv -load_dotenv() +# python-dotenv is the single owner of .env — the systemd unit deliberately does +# not set EnvironmentFile, because the two parsers disagree about quoting and +# inline comments, and a value they disagree on fails silently and looks like a +# bad credential. +# +# The path is anchored to the project root rather than left to search upward +# from the working directory, so the same file is picked up whether the bot is +# started by systemd, from a shell anywhere, or by the test runner. +PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) +load_dotenv(os.path.join(PROJECT_ROOT, ".env")) # ── Bot ─────────────────────────────────────────────────────────────── DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") @@ -70,9 +79,9 @@ def runtime_versions() -> dict: deployed without git, or without FFmpeg on PATH, should still start and say so plainly. """ - root = os.path.dirname(os.path.abspath(__file__)) return { - "commit": _first_line(["git", "rev-parse", "--short", "HEAD"], cwd=root) or "unknown", + "commit": _first_line( + ["git", "rev-parse", "--short", "HEAD"], cwd=PROJECT_ROOT) or "unknown", "yt_dlp": _first_line([sys.executable, "-m", "yt_dlp", "--version"]) or "unknown", "ffmpeg": _ffmpeg_version(), "python": platform.python_version(), diff --git a/deploy/setup.sh b/deploy/setup.sh index 05d2a8d..081513b 100644 --- a/deploy/setup.sh +++ b/deploy/setup.sh @@ -75,7 +75,10 @@ Wants=network-online.target Type=simple User=$APP_USER WorkingDirectory=$APP_DIR -EnvironmentFile=$APP_DIR/.env +# 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 diff --git a/tests/test_config.py b/tests/test_config.py index 41f53e1..376c315 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -135,3 +135,30 @@ def test_log_runtime_leaks_no_secrets(caplog, monkeypatch): with caplog.at_level("INFO", logger="loopify"): config.log_runtime() assert "super-secret-token-value" not in caplog.text + + +# -- .env ownership ---------------------------------------------------- + +def test_dotenv_is_anchored_to_the_project_root(): + """ + Searching upward from the working directory would pick up a different .env + depending on where the bot was started from. + """ + import os + assert os.path.isfile(os.path.join(config.PROJECT_ROOT, "config.py")) + + +def test_the_systemd_unit_does_not_also_parse_env(): + """ + Two parsers over one file disagree on quoting and fail silently. Only + python-dotenv reads .env; the unit must not declare EnvironmentFile. + """ + 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 == []