AI Implementation feature(1082): Project Scaffold and Docker Packaging (#1)
This commit was merged in pull request #1.
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
+18
@@ -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/
|
||||||
+40
@@ -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"]
|
||||||
@@ -1,2 +1,50 @@
|
|||||||
# 10Backward
|
# 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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
Executable
+23
@@ -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)"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
SHELL=/bin/bash
|
||||||
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
@@ -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
|
||||||
Executable
+55
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
pythonpath = src
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pytest==8.3.3
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
python-dotenv==1.0.1
|
||||||
|
python-frontmatter==1.1.0
|
||||||
|
GitPython==3.1.43
|
||||||
|
Mastodon.py==1.8.0
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""10Backward — Mastodon daily-throwback bot."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .main import main
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user