diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7c8421e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.env +.env.example +data/ +.git/ +.venv/ +venv/ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +.pytest_cache/ +.coverage +tests/ +.kilo/ +kilo.json +setup.sh +README.md +.dockerignore +.gitignore +.idea/ +.vscode/ +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..646a5fb --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# 10Backward configuration — placeholder values only. NEVER use real credentials here. + +MASTODON_BASE_URL=https://mastodon.example +MASTODON_ACCESS_TOKEN=replace-me +MASTODON_VISIBILITY=public +SITE_URL=https://blog.example.com +HASHTAGS=#throwback,#10backward +THROWBACK_PREFIX=Throwback: +RETRY_COUNT=3 +RUN_AT=09:00 +TZ=Europe/Berlin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a93393 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.env +.env.* +!/.env.example +data/ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +.venv/ +venv/ +.pytest_cache/ +.coverage +htmlcov/ +*.log +.DS_Store +.idea/ +.vscode/ +.kilo/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ce32858 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM python:3.11-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + TZ=Europe/Berlin + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + cron \ + git \ + ca-certificates \ + tzdata \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd --system bot \ + && useradd --system --gid bot --home /app --shell /usr/sbin/nologin bot + +WORKDIR /app + +RUN mkdir -p /app/data \ + && chown -R bot:bot /app + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r /app/requirements.txt + +COPY --chown=bot:bot crontab/ /app/crontab/ +COPY --chown=bot:bot entrypoint.sh /app/entrypoint.sh +COPY --chown=bot:bot src/ /app/src/ + +RUN chmod 0755 /app/entrypoint.sh \ + && chmod 0755 /app/crontab/install-cron.sh \ + && chmod 0644 /app/crontab/tenbackward.cron + +ENV PYTHONPATH=/app/src + +USER bot + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/README.md b/README.md index 3c70e1e..fd4629a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,50 @@ # 10Backward +Daily Mastodon throwback bot scaffolded with Docker Compose. + +The container runs a cron daemon that invokes the Python entrypoint once per +day at the configured `RUN_AT` (Europe/Berlin default). The actual blog +clone / post pipeline is intentionally stubbed in this scaffold and is to be +implemented in a follow-up job — this repository currently ships: + +- Pinned Python dependency manifest. +- `Dockerfile` (python:3.11-slim + cron + git + ca-certificates + tzdata). +- `docker-compose.yml` building locally, mounting `.env` read-only and + persisting `./data` for the blog clone and `posted.json` state. +- `entrypoint.sh` that validates required env vars and fails clearly on + startup before launching `cron -f` in the foreground. +- Safe `.env.example` with placeholder credentials only. + +## Build & run + +```bash +cp .env.example .env # fill in real credentials locally +docker compose build +docker compose up -d +docker compose logs -f bot +``` + +`./data` is persisted across restarts on the host. The blog clone (added in a +follow-up job) will live in `./data/blog/`, and posted-state in +`./data/posted.json`. + +## Configuration + +| Variable | Required | Default | Notes | +| --- | --- | --- | --- | +| `MASTODON_BASE_URL` | yes | — | e.g. `https://mastodon.social` | +| `MASTODON_ACCESS_TOKEN` | yes | — | never commit | +| `SITE_URL` | yes | — | source blog URL (HTTPS) | +| `HASHTAGS` | yes | — | comma-separated, e.g. `#throwback,#10backward` | +| `RUN_AT` | yes | `09:00` | `HH:MM` in `TZ` | +| `MASTODON_VISIBILITY` | no | `public` | `public`, `unlisted`, `private`, `direct` | +| `THROWBACK_PREFIX` | no | `Throwback:` | German-localizable | +| `RETRY_COUNT` | no | `3` | post retries | +| `TZ` | no | `Europe/Berlin` | any IANA timezone | + +## Tests + +```bash +pip install -r requirements-dev.txt +pytest +``` diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..5d15a76 --- /dev/null +++ b/build.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive + +apt-get update +apt-get install -y --no-install-recommends \ + bash \ + git \ + ca-certificates \ + python3 \ + python3-pip \ + python3-venv \ + tzdata + +rm -rf /var/lib/apt/lists/* + +PYTHON_BIN="$(command -v python3)" +PIP_BIN="$(command -v pip3 || true)" +if [ -z "${PIP_BIN}" ] && [ -n "${PYTHON_BIN}" ]; then + PIP_BIN="${PYTHON_BIN} -m pip" +fi + +if [ -n "${PIP_BIN}" ]; then + ${PIP_BIN} install --no-cache-dir --upgrade pip >/dev/null 2>&1 || true + ${PIP_BIN} install --no-cache-dir --break-system-packages -r /repo/requirements-dev.txt || \ + ${PIP_BIN} install --no-cache-dir -r /repo/requirements-dev.txt +fi + +cd /repo +PYTHONPATH=/repo/src pytest -q diff --git a/crontab/install-cron.sh b/crontab/install-cron.sh new file mode 100755 index 0000000..c1862d5 --- /dev/null +++ b/crontab/install-cron.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail + +CRON_FILE="${1:-/etc/cron.d/tenbackward}" + +if [ ! -f "${CRON_FILE}" ]; then + echo "install-cron: cron file ${CRON_FILE} does not exist" >&2 + exit 1 +fi + +if grep -q '__RUN_AT__' "${CRON_FILE}"; then + echo "install-cron: refusing to install cron file with unresolved __RUN_AT__ placeholder" >&2 + exit 1 +fi + +chmod 0644 "${CRON_FILE}" + +if ! head -n 1 "${CRON_FILE}" | grep -qE '^[A-Z_]+='; then + echo "install-cron: ${CRON_FILE} must start with a cron env line (e.g. SHELL=/bin/bash)" >&2 + exit 1 +fi + +echo "install-cron: ${CRON_FILE} installed (mode 0644)" diff --git a/crontab/tenbackward.cron b/crontab/tenbackward.cron new file mode 100644 index 0000000..e3baca0 --- /dev/null +++ b/crontab/tenbackward.cron @@ -0,0 +1,2 @@ +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..381c3a8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + bot: + build: + context: . + dockerfile: Dockerfile + image: tenbackward-bot:latest + container_name: tenbackward-bot + restart: unless-stopped + env_file: + - .env + environment: + MASTODON_BASE_URL: ${MASTODON_BASE_URL:-https://mastodon.example} + MASTODON_ACCESS_TOKEN: ${MASTODON_ACCESS_TOKEN:-replace-me} + MASTODON_VISIBILITY: ${MASTODON_VISIBILITY:-public} + SITE_URL: ${SITE_URL:-https://blog.example.com} + HASHTAGS: ${HASHTAGS:-#throwback,#10backward} + THROWBACK_PREFIX: ${THROWBACK_PREFIX:-Throwback:} + RETRY_COUNT: ${RETRY_COUNT:-3} + RUN_AT: ${RUN_AT:-09:00} + TZ: ${TZ:-Europe/Berlin} + volumes: + - ./.env:/.env:ro + - ./data:/app/data diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..f385358 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,55 @@ +#!/bin/bash +set -euo pipefail + +export DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}" +export TZ="${TZ:-Europe/Berlin}" + +if [ -f /usr/share/zoneinfo/"${TZ}" ] && [ ! -f /etc/localtime ]; then + ln -snf /usr/share/zoneinfo/"${TZ}" /etc/localtime || true +fi + +required_vars=( + MASTODON_BASE_URL + MASTODON_ACCESS_TOKEN + SITE_URL + HASHTAGS + RUN_AT +) + +missing=() +for var in "${required_vars[@]}"; do + if [ -z "${!var:-}" ]; then + missing+=("${var}") + fi +done + +if [ "${#missing[@]}" -gt 0 ]; then + echo "ERROR: missing required environment variable(s): ${missing[*]}" >&2 + exit 1 +fi + +run_at="${RUN_AT}" +if ! [[ "${run_at}" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]]; then + echo "ERROR: RUN_AT='${run_at}' must be in HH:MM (24-hour) format" >&2 + exit 1 +fi + +hour="${run_at%%:*}" +minute="${run_at##*:}" + +tmp_cron="$(mktemp)" +trap 'rm -f "${tmp_cron}"' EXIT + +{ + echo "SHELL=/bin/bash" + echo "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + echo "TZ=${TZ}" + echo "${minute} ${hour} * * * cd /app && /usr/local/bin/python -m tenbackward >> /app/data/cron.log 2>&1" +} > "${tmp_cron}" + +install -m 0644 -o root -g root "${tmp_cron}" /etc/cron.d/tenbackward + +/app/crontab/install-cron.sh /etc/cron.d/tenbackward + +echo "tenbackward: starting cron (RUN_AT=${run_at}, TZ=${TZ})" +exec cron -f diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..80432c2 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +pythonpath = src diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..40543aa --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +pytest==8.3.3 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e43f99e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +python-dotenv==1.0.1 +python-frontmatter==1.1.0 +GitPython==3.1.43 +Mastodon.py==1.8.0 diff --git a/setup.sh b/setup.sh index 8b69a73..5d15a76 100644 --- a/setup.sh +++ b/setup.sh @@ -3,4 +3,28 @@ set -euo pipefail export DEBIAN_FRONTEND=noninteractive apt-get update -apt-get install -y bash +apt-get install -y --no-install-recommends \ + bash \ + git \ + ca-certificates \ + python3 \ + python3-pip \ + python3-venv \ + tzdata + +rm -rf /var/lib/apt/lists/* + +PYTHON_BIN="$(command -v python3)" +PIP_BIN="$(command -v pip3 || true)" +if [ -z "${PIP_BIN}" ] && [ -n "${PYTHON_BIN}" ]; then + PIP_BIN="${PYTHON_BIN} -m pip" +fi + +if [ -n "${PIP_BIN}" ]; then + ${PIP_BIN} install --no-cache-dir --upgrade pip >/dev/null 2>&1 || true + ${PIP_BIN} install --no-cache-dir --break-system-packages -r /repo/requirements-dev.txt || \ + ${PIP_BIN} install --no-cache-dir -r /repo/requirements-dev.txt +fi + +cd /repo +PYTHONPATH=/repo/src pytest -q diff --git a/src/tenbackward/__init__.py b/src/tenbackward/__init__.py new file mode 100644 index 0000000..5f11cee --- /dev/null +++ b/src/tenbackward/__init__.py @@ -0,0 +1,3 @@ +"""10Backward — Mastodon daily-throwback bot.""" + +__version__ = "0.1.0" diff --git a/src/tenbackward/__main__.py b/src/tenbackward/__main__.py new file mode 100644 index 0000000..774cc25 --- /dev/null +++ b/src/tenbackward/__main__.py @@ -0,0 +1,3 @@ +from .main import main + +raise SystemExit(main()) diff --git a/src/tenbackward/blog.py b/src/tenbackward/blog.py new file mode 100644 index 0000000..2fc73bd --- /dev/null +++ b/src/tenbackward/blog.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from pathlib import Path +from urllib.parse import urlparse + + +def clone_or_update(site_url: str, dest: Path) -> Path: + """Clone or update the source blog git repository under `dest`. + + Stubbed for the scaffold (Job 1082). Future jobs will implement this + using GitPython over HTTPS. + """ + parsed = urlparse(site_url) + if parsed.scheme not in {"http", "https"}: + raise ValueError(f"unsupported SITE_URL scheme: {parsed.scheme!r}") + raise NotImplementedError( + "blog clone/update lands in a follow-up job (relies on GitPython over HTTPS)" + ) + + +def blog_dir(data_dir: Path) -> Path: + return Path(data_dir) / "blog" diff --git a/src/tenbackward/config.py b/src/tenbackward/config.py new file mode 100644 index 0000000..f5c84ae --- /dev/null +++ b/src/tenbackward/config.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from dotenv import dotenv_values, load_dotenv + + +REQUIRED_KEYS = ( + "MASTODON_BASE_URL", + "MASTODON_ACCESS_TOKEN", + "SITE_URL", + "HASHTAGS", + "RUN_AT", +) + +OPTIONAL_KEYS = ( + "MASTODON_VISIBILITY", + "THROWBACK_PREFIX", + "RETRY_COUNT", + "TZ", +) + +DEFAULTS = { + "MASTODON_VISIBILITY": "public", + "THROWBACK_PREFIX": "Throwback:", + "RETRY_COUNT": "3", + "TZ": "Europe/Berlin", + "RUN_AT": "09:00", +} + + +class ConfigError(ValueError): + """Raised when required configuration is missing or invalid.""" + + +@dataclass(frozen=True) +class Config: + mastodon_base_url: str + mastodon_access_token: str + mastodon_visibility: str + site_url: str + hashtags: str + throwback_prefix: str + retry_count: int + run_at: str + tz: str + data_dir: Path = field(default_factory=lambda: Path("/app/data")) + extra: dict = field(default_factory=dict) + + @property + def cron_minute(self) -> str: + return self.run_at.split(":", 1)[0] + + @property + def cron_hour(self) -> str: + return self.run_at.split(":", 1)[1] + + +def _read_dotenv(dotenv_path: Optional[Path]) -> dict[str, str]: + if dotenv_path is None: + return {} + if not dotenv_path.exists(): + return {} + values = dotenv_values(dotenv_path=str(dotenv_path)) + return {k: v for k, v in values.items() if v is not None} + + +def _values_from_env() -> dict[str, str]: + values: dict[str, str] = {} + for key in REQUIRED_KEYS + OPTIONAL_KEYS: + raw = os.environ.get(key) + if raw is not None and raw != "": + values[key] = raw + return values + + +def load_config(dotenv_path: Optional[Path] = None) -> Config: + """Load configuration from a dotenv file and the process environment. + + Environment variables take precedence over the dotenv file so a mounted + `.env` can be supplemented by Compose-level overrides. + """ + if dotenv_path is not None: + load_dotenv(dotenv_path=str(dotenv_path), override=False) + + file_values = _read_dotenv(dotenv_path) + env_values = _values_from_env() + + merged: dict[str, str] = {} + merged.update(file_values) + merged.update(env_values) + + apply_defaults(merged) + + validate_config(merged) + + data_dir = Path(os.environ.get("DATA_DIR", "/app/data")).resolve() + + extra = {k: v for k, v in merged.items() if k not in REQUIRED_KEYS + OPTIONAL_KEYS} + + return Config( + mastodon_base_url=merged["MASTODON_BASE_URL"], + mastodon_access_token=merged["MASTODON_ACCESS_TOKEN"], + mastodon_visibility=merged["MASTODON_VISIBILITY"], + site_url=merged["SITE_URL"], + hashtags=merged["HASHTAGS"], + throwback_prefix=merged["THROWBACK_PREFIX"], + retry_count=_parse_retry_count(merged["RETRY_COUNT"]), + run_at=merged["RUN_AT"], + tz=merged["TZ"], + data_dir=data_dir, + extra=extra, + ) + + +def apply_defaults(values: dict[str, str]) -> None: + for key, default in DEFAULTS.items(): + values.setdefault(key, default) + + +def validate_config(values: dict[str, str]) -> None: + missing = [k for k in REQUIRED_KEYS if not values.get(k)] + if missing: + raise ConfigError( + "missing required configuration key(s): " + ", ".join(missing) + ) + + run_at = values.get("RUN_AT", "") + if not _is_valid_hhmm(run_at): + raise ConfigError( + f"RUN_AT={run_at!r} must be in HH:MM (24-hour) format" + ) + + try: + _parse_retry_count(values.get("RETRY_COUNT", "")) + except ConfigError as exc: + raise ConfigError(str(exc)) from exc + + +def _is_valid_hhmm(value: str) -> bool: + if not isinstance(value, str): + return False + parts = value.split(":") + if len(parts) != 2: + return False + hour, minute = parts + if len(hour) != 2 or len(minute) != 2: + return False + if not hour.isdigit() or not minute.isdigit(): + return False + h = int(hour) + m = int(minute) + return 0 <= h <= 23 and 0 <= m <= 59 + + +def _parse_retry_count(value: str) -> int: + try: + count = int(value) + except (TypeError, ValueError): + raise ConfigError(f"RETRY_COUNT={value!r} must be a positive integer") + if count < 0: + raise ConfigError(f"RETRY_COUNT={value!r} must be >= 0") + return count diff --git a/src/tenbackward/main.py b/src/tenbackward/main.py new file mode 100644 index 0000000..7c3da6d --- /dev/null +++ b/src/tenbackward/main.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import logging +import sys + +from . import __version__ +from .config import ConfigError, load_config +from .state import load_posted, save_posted + + +log = logging.getLogger("tenbackward") + + +def main() -> int: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + try: + config = load_config() + except ConfigError as exc: + log.error("configuration error: %s", exc) + return 2 + + config.data_dir.mkdir(parents=True, exist_ok=True) + + state = load_posted(config.data_dir) + save_posted(config.data_dir, state) + + log.info( + "10backward v%s ready (site=%s, run_at=%s, tz=%s, hashtags=%s, prefix=%r)", + __version__, + config.site_url, + config.run_at, + config.tz, + config.hashtags, + config.throwback_prefix, + ) + + log.warning( + "post pipeline is not yet implemented; this run only validates the scaffold. " + "Future job will clone the blog and post a throwback via Mastodon.py." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/tenbackward/state.py b/src/tenbackward/state.py new file mode 100644 index 0000000..dff16e4 --- /dev/null +++ b/src/tenbackward/state.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def posted_path(data_dir: Path) -> Path: + return Path(data_dir) / "posted.json" + + +def load_posted(data_dir: Path) -> dict[str, Any]: + """Return the posted-state map, creating an empty one if missing.""" + path = posted_path(data_dir) + if not path.exists(): + return {} + try: + with path.open("r", encoding="utf-8") as fh: + data = json.load(fh) + except json.JSONDecodeError: + return {} + if not isinstance(data, dict): + return {} + return data + + +def save_posted(data_dir: Path, state: dict[str, Any]) -> None: + """Persist the posted-state map atomically-ish.""" + path = posted_path(data_dir) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + with tmp.open("w", encoding="utf-8") as fh: + json.dump(state, fh, sort_keys=True, indent=2) + tmp.replace(path) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..82b0edb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tenbackward.config import Config + + +@pytest.fixture() +def env_setup(monkeypatch: pytest.MonkeyPatch) -> None: + """Provide a fully populated environment for Config success paths. + + Tests that exercise the missing-required-key path should call + `monkeypatch.delenv(...)` explicitly. + """ + monkeypatch.setenv("MASTODON_BASE_URL", "https://mastodon.example") + monkeypatch.setenv("MASTODON_ACCESS_TOKEN", "test-token") + monkeypatch.setenv("SITE_URL", "https://blog.example.com") + monkeypatch.setenv("HASHTAGS", "#throwback,#10backward") + monkeypatch.setenv("RUN_AT", "09:00") + + +@pytest.fixture() +def data_dir(tmp_path: Path) -> Path: + return tmp_path / "data" + + +@pytest.fixture() +def full_config(env_setup, data_dir) -> Config: + """Build a Config object that points at an isolated data dir.""" + from tenbackward.config import load_config + + import os + + os.environ["DATA_DIR"] = str(data_dir) + return load_config() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..82f2aa6 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from tenbackward.config import ConfigError, load_config, validate_config + + +def _populate(env_setup) -> dict[str, str]: + return {k: os.environ[k] for k in os.environ if k.startswith(("MASTODON_", "SITE_", "HASHTAGS", "RUN_AT", "THROWBACK_", "RETRY_", "TZ"))} + + +def test_load_config_succeeds_with_complete_env(env_setup) -> None: + config = load_config() + assert config.mastodon_base_url == "https://mastodon.example" + assert config.run_at == "09:00" + assert config.retry_count == 3 + assert config.throwback_prefix == "Throwback:" + + +def test_validate_config_lists_every_missing_key(env_setup, monkeypatch) -> None: + monkeypatch.delenv("MASTODON_ACCESS_TOKEN") + monkeypatch.delenv("SITE_URL") + + values = {k: os.environ.get(k, "") for k in [ + "MASTODON_BASE_URL", + "MASTODON_ACCESS_TOKEN", + "SITE_URL", + "HASHTAGS", + "RUN_AT", + ]} + + with pytest.raises(ConfigError) as excinfo: + validate_config(values) + + message = str(excinfo.value) + assert "MASTODON_ACCESS_TOKEN" in message + assert "SITE_URL" in message + + +def test_validate_config_rejects_bad_run_at() -> None: + values = { + "MASTODON_BASE_URL": "https://mastodon.example", + "MASTODON_ACCESS_TOKEN": "x", + "SITE_URL": "https://blog.example.com", + "HASHTAGS": "#x", + "RUN_AT": "25:99", + "MASTODON_VISIBILITY": "public", + "THROWBACK_PREFIX": "Throwback:", + "RETRY_COUNT": "3", + "TZ": "Europe/Berlin", + } + with pytest.raises(ConfigError): + validate_config(values) + + +def test_load_config_applies_optional_defaults(env_setup, monkeypatch) -> None: + monkeypatch.delenv("THROWBACK_PREFIX", raising=False) + monkeypatch.delenv("RETRY_COUNT", raising=False) + + config = load_config() + assert config.throwback_prefix == "Throwback:" + assert config.retry_count == 3 + assert config.tz == "Europe/Berlin" + assert config.mastodon_visibility == "public" + + +def test_load_config_reads_dotenv_file(tmp_path: Path, monkeypatch) -> None: + dotenv = tmp_path / ".env" + dotenv.write_text( + "MASTODON_BASE_URL=https://from-file.example\n" + "MASTODON_ACCESS_TOKEN=file-token\n" + "SITE_URL=https://blog.example.com\n" + "HASHTAGS=#throwback\n" + "RUN_AT=12:34\n" + ) + + for key in ("MASTODON_BASE_URL", "MASTODON_ACCESS_TOKEN", "SITE_URL", "HASHTAGS", "RUN_AT"): + monkeypatch.delenv(key, raising=False) + + config = load_config(dotenv_path=dotenv) + assert config.mastodon_base_url == "https://from-file.example" + assert config.mastodon_access_token == "file-token" + assert config.run_at == "12:34" + + +def test_env_overrides_dotenv(tmp_path: Path, monkeypatch) -> None: + dotenv = tmp_path / ".env" + dotenv.write_text("RUN_AT=01:00\n") + + monkeypatch.setenv("RUN_AT", "23:00") + for key in ( + "MASTODON_BASE_URL", + "MASTODON_ACCESS_TOKEN", + "SITE_URL", + "HASHTAGS", + ): + monkeypatch.setenv(key, "x") + + config = load_config(dotenv_path=dotenv) + assert config.run_at == "23:00" diff --git a/tests/test_cron_render.py b/tests/test_cron_render.py new file mode 100644 index 0000000..bcb473d --- /dev/null +++ b/tests/test_cron_render.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tenbackward.config import _is_valid_hhmm + + +REPO_ROOT = Path(__file__).resolve().parents[1] +ENTRYPOINT = REPO_ROOT / "entrypoint.sh" + + +def test_valid_hhmm() -> None: + assert _is_valid_hhmm("00:00") + assert _is_valid_hhmm("09:30") + assert _is_valid_hhmm("23:59") + + +@pytest.mark.parametrize("value", ["24:00", "9:00", "09:60", "9", "9:00:00", "ab:cd", "", None]) +def test_invalid_hhmm(value) -> None: + assert not _is_valid_hhmm(value) # type: ignore[arg-type] + + +def _entrypoint_env(extra: dict[str, str]) -> dict[str, str]: + env = { + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "DEBIAN_FRONTEND": "noninteractive", + "HOME": "/tmp", + "LANG": "C.UTF-8", + } + env.update(extra) + return env + + +def _run_entrypoint_cron_render(extra: dict[str, str]) -> str: + raise NotImplementedError + + +def _extract_rendered_cron(env: dict[str, str]) -> tuple[int, str, str]: + """Run the entrypoint with a fake `install` that records the cron file, + and a stub `cron` that exits immediately so the entrypoint doesn't hang. + Returns (returncode, stderr, installed-cron-contents). + """ + import tempfile + + with tempfile.TemporaryDirectory() as fake_bin_dir, tempfile.TemporaryDirectory() as tmp: + fake_cron = Path(tmp) / "tenbackward.cron" + + # Fake `install`: real syntax is `install [-m mode] [-o owner] [-g group] SRC DST`. + # We extract the last positional arg as DST and the last existing source before it. + fake_install = Path(fake_bin_dir) / "install" + fake_install.write_text( + "#!/bin/bash\n" + "src=\"\"\n" + "dst=\"\"\n" + "while [ $# -gt 0 ]; do\n" + " case \"$1\" in\n" + " -m|-o|-g) shift; shift;;\n" + " *) src=\"${src:-$1}\"; dst=\"$1\"; shift;;\n" + " esac\n" + "done\n" + "cp \"$src\" \"" + str(fake_cron) + "\"\n" + ) + fake_install.chmod(0o755) + + # Fake `cron` exits immediately so we never hang waiting on cron. + fake_cron_bin = Path(fake_bin_dir) / "cron" + fake_cron_bin.write_text("#!/bin/bash\nexit 0\n") + fake_cron_bin.chmod(0o755) + + env = dict(env) + env["PATH"] = f"{fake_bin_dir}:/usr/bin:/bin" + + proc = subprocess.run( + ["/bin/bash", str(ENTRYPOINT)], + capture_output=True, + text=True, + env=env, + timeout=10, + check=False, + ) + + rendered = fake_cron.read_text() if fake_cron.exists() else "" + return proc.returncode, proc.stderr, rendered + + +@pytest.mark.skipif(not shutil.which("bash"), reason="bash not available") +def test_entrypoint_renders_run_at_into_cron() -> None: + """Drive the entrypoint with a hermetic PATH so we can observe the rendered cron line. + + The entrypoint hard-codes `/app/crontab/install-cron.sh` (correct in the + Docker image). For the host-side test we override PATH so `install` and + `cron` are stubbed, but we still need `/app/crontab/install-cron.sh` — we + bind-mount it via a bind-mount emulation by overriding the env var + `PATH` and having the install-cron.sh check fall back: instead, we patch + the entrypoint with an in-place substitution for the test. + """ + tmp_cron_dest = _write_substituted_entrypoint() + try: + env = _entrypoint_env({ + "MASTODON_BASE_URL": "https://mastodon.example", + "MASTODON_ACCESS_TOKEN": "x", + "SITE_URL": "https://blog.example.com", + "HASHTAGS": "#throwback", + "RUN_AT": "09:00", + "TZ": "Europe/Berlin", + }) + + returncode, stderr, rendered = _extract_rendered_cron(env, tmp_cron_dest) + + assert returncode == 0, (returncode, stderr) + assert "00 09 * * *" in rendered, rendered + assert "python -m tenbackward" in rendered + assert "TZ=Europe/Berlin" in rendered + finally: + tmp_cron_dest.unlink(missing_ok=True) + + +def _write_substituted_entrypoint(): + """Copy entrypoint.sh to a temp file with `/app/crontab/install-cron.sh` + rewritten to the repo-relative path so the host can execute it.""" + import tempfile + src = ENTRYPOINT.read_text() + sub = src.replace("/app/crontab/install-cron.sh", str(REPO_ROOT / "crontab" / "install-cron.sh")) + tmp = Path(tempfile.mkstemp(prefix="entrypoint-", suffix=".sh")[1]) + tmp.write_text(sub) + tmp.chmod(0o755) + return tmp + + +def _extract_rendered_cron(env: dict[str, str], entrypoint_path: Path) -> tuple[int, str, str]: + """Run the (substituted) entrypoint with stubs for `install` and `cron`.""" + import tempfile + + with tempfile.TemporaryDirectory() as fake_bin_dir, tempfile.TemporaryDirectory() as tmp: + sidecar = Path(tmp) / "tenbackward.cron" + + fake_install = Path(fake_bin_dir) / "install" + fake_install.write_text( + "#!/bin/bash\n" + "src=\"\"; dst=\"\"\n" + "while [ $# -gt 0 ]; do\n" + " case \"$1\" in -m|-o|-g) shift; shift;; *) src=\"${src:-$1}\"; dst=\"$1\"; shift;; esac\n" + "done\n" + "cp \"$src\" \"" + str(sidecar) + "\"\n" + "mkdir -p \"$(dirname \"$dst\")\" 2>/dev/null\n" + "cp \"$src\" \"$dst\" 2>/dev/null || true\n" + ) + fake_install.chmod(0o755) + + fake_cron_bin = Path(fake_bin_dir) / "cron" + fake_cron_bin.write_text("#!/bin/bash\nexit 0\n") + fake_cron_bin.chmod(0o755) + + env = dict(env) + env["PATH"] = f"{fake_bin_dir}:/usr/bin:/bin" + + proc = subprocess.run( + ["/bin/bash", str(entrypoint_path)], + capture_output=True, + text=True, + env=env, + timeout=10, + check=False, + ) + + rendered = sidecar.read_text() if sidecar.exists() else "" + return proc.returncode, proc.stderr, rendered + + +@pytest.mark.skipif(not shutil.which("bash"), reason="bash not available") +def test_install_cron_rejects_unresolved_placeholder(tmp_path: Path) -> None: + script = REPO_ROOT / "crontab" / "install-cron.sh" + + bad = tmp_path / "bad.cron" + bad.write_text("__RUN_AT__ * * * * /bin/false\n") + proc = subprocess.run( + ["/bin/bash", str(script), str(bad)], + capture_output=True, text=True, timeout=10, + ) + assert proc.returncode != 0 + assert "__RUN_AT__" in proc.stderr + + good = tmp_path / "good.cron" + good.write_text("SHELL=/bin/bash\n5 9 * * * /bin/true\n") + proc2 = subprocess.run( + ["/bin/bash", str(script), str(good)], + capture_output=True, text=True, timeout=10, + ) + assert proc2.returncode == 0, proc2.stderr diff --git a/tests/test_docker_artifacts.py b/tests/test_docker_artifacts.py new file mode 100644 index 0000000..2ed91dc --- /dev/null +++ b/tests/test_docker_artifacts.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def test_dockerignore_blocks_secrets_and_data() -> None: + dockerignore = (REPO_ROOT / ".dockerignore").read_text().splitlines() + normalized = {line.strip() for line in dockerignore if line.strip() and not line.startswith("#")} + + for required in (".env", "data/", ".git/", "tests/", ".kilo/", ".venv/"): + assert required in normalized, f"missing {required} from .dockerignore" + + +def test_gitignore_blocks_dotenv_and_data() -> None: + gitignore = (REPO_ROOT / ".gitignore").read_text().splitlines() + normalized = {line.strip() for line in gitignore if line.strip() and not line.startswith("#")} + assert ".env" in normalized + assert "data/" in normalized + assert ".kilo/" in normalized + + +def test_env_example_has_no_real_tokens() -> None: + text = (REPO_ROOT / ".env.example").read_text() + assert "MASTODON_ACCESS_TOKEN=replace-me" in text + + for token in ("ghp_", "gho_", "xoxb-", "Bearer ey", "AKIA"): + assert token not in text, f"found suspicious token prefix {token!r} in .env.example" + + mastodon_line = next( + line for line in text.splitlines() if line.startswith("MASTODON_ACCESS_TOKEN=") + ) + value = mastodon_line.split("=", 1)[1].strip() + assert not re.fullmatch(r"[A-Za-z0-9_\-]{40,}", value), ( + "MASTODON_ACCESS_TOKEN placeholder should not look like a real token" + ) + + +def test_dockerfile_does_not_copy_env_or_data() -> None: + dockerfile = (REPO_ROOT / "Dockerfile").read_text() + for blocked in (".env", "data/", "./data"): + for line in dockerfile.splitlines(): + if line.lstrip().upper().startswith("COPY"): + assert blocked not in line, f"Dockerfile COPY must not reference {blocked}: {line!r}" + + +def test_compose_mounts_data_and_env_readonly() -> None: + compose = (REPO_ROOT / "docker-compose.yml").read_text() + assert "./.env:/.env:ro" in compose + assert "./data:/app/data" in compose + assert "build:" in compose + + +def test_cron_template_has_env_header() -> None: + cron = (REPO_ROOT / "crontab" / "tenbackward.cron").read_text() + assert "SHELL=/bin/bash" in cron + assert "PATH=" in cron diff --git a/tests/test_entrypoint_validation.py b/tests/test_entrypoint_validation.py new file mode 100644 index 0000000..d1907b9 --- /dev/null +++ b/tests/test_entrypoint_validation.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +ENTRYPOINT = REPO_ROOT / "entrypoint.sh" + + +def _has_bash() -> bool: + return shutil.which("bash") is not None + + +def _minimal_path() -> str: + parts = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] + for p in parts: + if Path(p).exists(): + return ":".join(parts) + return os.environ.get("PATH", "") + + +def _clean_env() -> dict[str, str]: + env = { + "PATH": _minimal_path(), + "DEBIAN_FRONTEND": "noninteractive", + "HOME": "/tmp", + "LANG": "C.UTF-8", + } + return env + + +@pytest.mark.skipif(not _has_bash(), reason="bash not available") +def test_entrypoint_rejects_bad_run_at(tmp_path: Path, monkeypatch) -> None: + """The entrypoint must exit non-zero with a clear message on a bad RUN_AT.""" + env = _clean_env() + env["MASTODON_BASE_URL"] = "https://mastodon.example" + env["MASTODON_ACCESS_TOKEN"] = "x" + env["SITE_URL"] = "https://blog.example.com" + env["HASHTAGS"] = "#throwback" + env["RUN_AT"] = "25:99" + env["TZ"] = "Europe/Berlin" + + proc = subprocess.run( + ["/bin/bash", str(ENTRYPOINT)], + capture_output=True, + text=True, + env=env, + timeout=10, + check=False, + ) + assert proc.returncode != 0 + assert "RUN_AT" in (proc.stdout + proc.stderr) + + +@pytest.mark.skipif(not _has_bash(), reason="bash not available") +def test_entrypoint_fails_fast_when_token_missing(tmp_path: Path, monkeypatch) -> None: + """A missing required var must abort before cron is started.""" + env = _clean_env() + env["MASTODON_BASE_URL"] = "https://mastodon.example" + env["SITE_URL"] = "https://blog.example.com" + env["HASHTAGS"] = "#throwback" + env["RUN_AT"] = "09:00" + env["TZ"] = "Europe/Berlin" + env.pop("MASTODON_ACCESS_TOKEN", None) + + proc = subprocess.run( + ["/bin/bash", str(ENTRYPOINT)], + capture_output=True, + text=True, + env=env, + timeout=10, + check=False, + ) + assert proc.returncode != 0 + combined = (proc.stdout + proc.stderr).lower() + assert "missing" in combined + assert "mastodon_access_token" in combined.lower() + + +def test_entrypoint_references_cron_in_foreground() -> None: + text = ENTRYPOINT.read_text() + assert re.search(r"exec\s+cron\s+-f", text), "entrypoint must exec cron in the foreground" + cron = (REPO_ROOT / "crontab" / "tenbackward.cron").read_text() + assert "SHELL=/bin/bash" in cron diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..bc96d54 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from pathlib import Path + +from tenbackward.state import load_posted, posted_path, save_posted + + +def test_load_posted_creates_empty_when_missing(tmp_path: Path) -> None: + state = load_posted(tmp_path) + assert state == {} + + +def test_round_trip_persists(tmp_path: Path) -> None: + state = {"post-1": {"posted_at": "2024-01-01T00:00:00Z"}} + save_posted(tmp_path, state) + + assert posted_path(tmp_path).exists() + again = load_posted(tmp_path) + assert again == state + + +def test_load_posted_handles_corrupt_file(tmp_path: Path) -> None: + posted_path(tmp_path).parent.mkdir(parents=True, exist_ok=True) + posted_path(tmp_path).write_text("not-json") + assert load_posted(tmp_path) == {} + + +def test_save_posted_creates_parent_dirs(tmp_path: Path) -> None: + nested = tmp_path / "deep" / "data" + save_posted(nested, {"x": 1}) + assert posted_path(nested).exists()