AI Implementation feature(1084): Blog Repository Clone and Pull Workflow #3
@@ -9,3 +9,7 @@ THROWNBACK_PREFIX=Throwback:
|
|||||||
MAX_RETRIES=3
|
MAX_RETRIES=3
|
||||||
RUN_AT=09:00
|
RUN_AT=09:00
|
||||||
TZ=Europe/Berlin
|
TZ=Europe/Berlin
|
||||||
|
|
||||||
|
# Optional — Jekyll blog clone source. Defaults point at the Chaospott public repo.
|
||||||
|
# BLOG_REPO_URL=https://git.chaospott.de/Chaospott/site
|
||||||
|
# BLOG_DIR=blog
|
||||||
|
|||||||
@@ -42,12 +42,14 @@ environment for the container to boot.
|
|||||||
fills into the merged map **before** validation runs:
|
fills into the merged map **before** validation runs:
|
||||||
|
|
||||||
| Default key | Default value |
|
| Default key | Default value |
|
||||||
|-----------------------|-------------------|
|
|-----------------------|-------------------------------------------|
|
||||||
| `VISIBILITY` | `public` |
|
| `VISIBILITY` | `public` |
|
||||||
| `THROWNBACK_PREFIX` | `Throwback:` |
|
| `THROWNBACK_PREFIX` | `Throwback:` |
|
||||||
| `MAX_RETRIES` | `3` |
|
| `MAX_RETRIES` | `3` |
|
||||||
| `TZ` | `Europe/Berlin` |
|
| `TZ` | `Europe/Berlin` |
|
||||||
| `RUN_AT` | `09:00` |
|
| `RUN_AT` | `09:00` |
|
||||||
|
| `BLOG_REPO_URL` | `https://git.chaospott.de/Chaospott/site` |
|
||||||
|
| `BLOG_DIR` | `blog` (resolved relative to `data_dir`) |
|
||||||
|
|
||||||
Because every required key carries a default, a freshly-initialised
|
Because every required key carries a default, a freshly-initialised
|
||||||
container can still boot to validate the scaffold, but the operator
|
container can still boot to validate the scaffold, but the operator
|
||||||
@@ -68,6 +70,8 @@ ideally `SITE_URL`) to make a real run.
|
|||||||
| `run_at` | `str` | `RUN_AT` |
|
| `run_at` | `str` | `RUN_AT` |
|
||||||
| `tz` | `str` | `TZ` |
|
| `tz` | `str` | `TZ` |
|
||||||
| `data_dir` | `Path` | Constant: `Path("/app/data")` |
|
| `data_dir` | `Path` | Constant: `Path("/app/data")` |
|
||||||
|
| `blog_repo_url` | `str` | `BLOG_REPO_URL` |
|
||||||
|
| `blog_dir` | `Path` | `BLOG_DIR` resolved against `data_dir` |
|
||||||
|
|
||||||
# Validation Behaviour
|
# Validation Behaviour
|
||||||
|
|
||||||
@@ -80,6 +84,8 @@ problem joined with `"; "`. Validation covers:
|
|||||||
* `MASTODON_BASE_URL` and `SITE_URL` parse via `urllib.parse.urlparse`
|
* `MASTODON_BASE_URL` and `SITE_URL` parse via `urllib.parse.urlparse`
|
||||||
with an `http`/`https` scheme and a non-empty `netloc`.
|
with an `http`/`https` scheme and a non-empty `netloc`.
|
||||||
* `VISIBILITY` is one of `{"public", "unlisted"}` (`ALLOWED_VISIBILITY`).
|
* `VISIBILITY` is one of `{"public", "unlisted"}` (`ALLOWED_VISIBILITY`).
|
||||||
|
* `BLOG_REPO_URL` (optional, has default) parses via `urllib.parse.urlparse`
|
||||||
|
with an `http`/`https` scheme and a non-empty `netloc` when present.
|
||||||
* `TZ` resolves via `zoneinfo.ZoneInfo`.
|
* `TZ` resolves via `zoneinfo.ZoneInfo`.
|
||||||
* `MAX_RETRIES` parses as a non-negative integer (rejects bools).
|
* `MAX_RETRIES` parses as a non-negative integer (rejects bools).
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ foreground of the `cron` process. Each scheduled invocation calls
|
|||||||
| `tenbackward.config` | Loads `.env` + process env, applies defaults, validates schema, produces a typed `Config` dataclass. |
|
| `tenbackward.config` | Loads `.env` + process env, applies defaults, validates schema, produces a typed `Config` dataclass. |
|
||||||
| `tenbackward.logging_setup` | JSON formatter (one log record per line), secret redaction, and `log_startup` / `log_error` / `log_run_summary` helpers. |
|
| `tenbackward.logging_setup` | JSON formatter (one log record per line), secret redaction, and `log_startup` / `log_error` / `log_run_summary` helpers. |
|
||||||
| `tenbackward.state` | Read/write of `posted.json` (atomic temp-file replace). |
|
| `tenbackward.state` | Read/write of `posted.json` (atomic temp-file replace). |
|
||||||
| `tenbackward.blog` | Stubbed blog clone/update helper (lands in a follow-up job). |
|
| `tenbackward.blog` | GitPython `ensure_repo()` — clones the configured blog repo on first run, fast-forwards it via `pull --ff-only` thereafter, with exponential-backoff retries on transient network errors. |
|
||||||
| `/etc/cron.d/tenbackward`| Rendered cron file. One daily line that `cd /app` and runs `python -m tenbackward`. |
|
| `/etc/cron.d/tenbackward`| Rendered cron file. One daily line that `cd /app` and runs `python -m tenbackward`. |
|
||||||
|
|
||||||
# Communication & Wiring
|
# Communication & Wiring
|
||||||
@@ -80,7 +80,7 @@ foreground of the `cron` process. Each scheduled invocation calls
|
|||||||
| `/repo/src/tenbackward/config.py` | Env merging, defaults, validation, `Config` dataclass, `ConfigError`. |
|
| `/repo/src/tenbackward/config.py` | Env merging, defaults, validation, `Config` dataclass, `ConfigError`. |
|
||||||
| `/repo/src/tenbackward/logging_setup.py` | JSON formatter, secret redaction, structured event helpers. |
|
| `/repo/src/tenbackward/logging_setup.py` | JSON formatter, secret redaction, structured event helpers. |
|
||||||
| `/repo/src/tenbackward/state.py` | `posted.json` read/write with atomic temp-file replace. |
|
| `/repo/src/tenbackward/state.py` | `posted.json` read/write with atomic temp-file replace. |
|
||||||
| `/repo/src/tenbackward/blog.py` | Stubbed blog clone helper (not yet wired into the pipeline). |
|
| `/repo/src/tenbackward/blog.py` | `ensure_repo()` — GitPython clone + fast-forward pull with `2^n` retry/backoff; raises `BlogRepoError` on local modifications or exhausted retries. |
|
||||||
| `/repo/.env.example` | Canonical list of environment variables. |
|
| `/repo/.env.example` | Canonical list of environment variables. |
|
||||||
| `/repo/docker-compose.yml` | Service definition; binds env vars from the host `.env`. |
|
| `/repo/docker-compose.yml` | Service definition; binds env vars from the host `.env`. |
|
||||||
| `/repo/Dockerfile` | Builds the runtime image. |
|
| `/repo/Dockerfile` | Builds the runtime image. |
|
||||||
|
|||||||
@@ -2,6 +2,16 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "${SCRIPT_DIR}"
|
||||||
|
|
||||||
|
if [ -w /usr/local/bin ] && [ ! -e /usr/local/bin/build.sh ]; then
|
||||||
|
ln -s "${SCRIPT_DIR}/build.sh" /usr/local/bin/build.sh || true
|
||||||
|
fi
|
||||||
|
if [ -w /usr/local/bin ] && [ ! -e /usr/local/bin/setup.sh ]; then
|
||||||
|
ln -s "${SCRIPT_DIR}/setup.sh" /usr/local/bin/setup.sh || true
|
||||||
|
fi
|
||||||
|
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
bash \
|
bash \
|
||||||
@@ -22,6 +32,8 @@ fi
|
|||||||
|
|
||||||
if [ -n "${PIP_BIN}" ]; then
|
if [ -n "${PIP_BIN}" ]; then
|
||||||
${PIP_BIN} install --no-cache-dir --upgrade pip >/dev/null 2>&1 || true
|
${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.txt || \
|
||||||
|
${PIP_BIN} install --no-cache-dir -r /repo/requirements.txt
|
||||||
${PIP_BIN} install --no-cache-dir --break-system-packages -r /repo/requirements-dev.txt || \
|
${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
|
${PIP_BIN} install --no-cache-dir -r /repo/requirements-dev.txt
|
||||||
fi
|
fi
|
||||||
|
|||||||
+164
-12
@@ -1,22 +1,174 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
from typing import Callable
|
||||||
|
|
||||||
|
import git
|
||||||
|
from git import GitCommandError, InvalidGitRepositoryError, NoSuchPathError, Repo
|
||||||
|
|
||||||
|
|
||||||
def clone_or_update(site_url: str, dest: Path) -> Path:
|
DEFAULT_REPO_URL = "https://git.chaospott.de/Chaospott/site"
|
||||||
"""Clone or update the source blog git repository under `dest`.
|
DEFAULT_BLOG_SUBDIR = "blog"
|
||||||
|
MAX_RETRIES_DEFAULT = 5
|
||||||
|
BACKOFF_BASE_SECONDS = 2
|
||||||
|
REMOTE_NAME = "origin"
|
||||||
|
BRANCH_NAME = "master"
|
||||||
|
|
||||||
Stubbed for the scaffold (Job 1082). Future jobs will implement this
|
_LOCAL_MODIFICATION_MARKERS = (
|
||||||
using GitPython over HTTPS.
|
"Your local changes",
|
||||||
"""
|
"would be overwritten",
|
||||||
parsed = urlparse(site_url)
|
"Please commit your changes",
|
||||||
if parsed.scheme not in {"http", "https"}:
|
"Please move or remove them",
|
||||||
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)"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BlogRepoError(RuntimeError):
|
||||||
|
"""Raised when the local blog working tree cannot be ensured."""
|
||||||
|
|
||||||
|
|
||||||
def blog_dir(data_dir: Path) -> Path:
|
def blog_dir(data_dir: Path) -> Path:
|
||||||
return Path(data_dir) / "blog"
|
return Path(data_dir) / DEFAULT_BLOG_SUBDIR
|
||||||
|
|
||||||
|
|
||||||
|
def blog_repo_url() -> str:
|
||||||
|
return os.environ.get("BLOG_REPO_URL") or DEFAULT_REPO_URL
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dir_empty(path: Path) -> bool:
|
||||||
|
if not path.exists():
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
next(path.iterdir())
|
||||||
|
except StopIteration:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_local_modification_error(exc: GitCommandError) -> bool:
|
||||||
|
message = str(exc)
|
||||||
|
return any(marker in message for marker in _LOCAL_MODIFICATION_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_and_pull(
|
||||||
|
repo: Repo,
|
||||||
|
*,
|
||||||
|
fetch: Callable[[], object],
|
||||||
|
pull: Callable[[], object],
|
||||||
|
sleep: Callable[[float], None],
|
||||||
|
max_retries: int,
|
||||||
|
logger: logging.Logger | None,
|
||||||
|
) -> None:
|
||||||
|
"""Fetch + fast-forward pull with retry/backoff for transient network errors.
|
||||||
|
|
||||||
|
Local-modification errors raise ``BlogRepoError`` immediately, without retry.
|
||||||
|
"""
|
||||||
|
attempts = max(1, max_retries + 1)
|
||||||
|
last_exc: GitCommandError | None = None
|
||||||
|
|
||||||
|
for attempt in range(1, attempts + 1):
|
||||||
|
try:
|
||||||
|
fetch()
|
||||||
|
pull()
|
||||||
|
return
|
||||||
|
except GitCommandError as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if _is_local_modification_error(exc):
|
||||||
|
if logger is not None:
|
||||||
|
logger.error(
|
||||||
|
"blog_local_modifications",
|
||||||
|
extra={"event": "blog_local_modifications", "error": str(exc)},
|
||||||
|
)
|
||||||
|
raise BlogRepoError(
|
||||||
|
"local modifications detected in blog working tree; aborting run"
|
||||||
|
) from exc
|
||||||
|
if logger is not None:
|
||||||
|
logger.error(
|
||||||
|
"blog_fetch_error",
|
||||||
|
extra={
|
||||||
|
"event": "blog_fetch_error",
|
||||||
|
"attempt": attempt,
|
||||||
|
"max_attempts": attempts,
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if attempt < attempts:
|
||||||
|
sleep(BACKOFF_BASE_SECONDS ** attempt)
|
||||||
|
|
||||||
|
assert last_exc is not None
|
||||||
|
raise BlogRepoError(
|
||||||
|
f"blog fetch/pull failed after {attempts} attempt(s): {last_exc}"
|
||||||
|
) from last_exc
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_repo(
|
||||||
|
data_dir: Path,
|
||||||
|
*,
|
||||||
|
repo_url: str | None = None,
|
||||||
|
blog_path: Path | None = None,
|
||||||
|
max_retries: int | None = None,
|
||||||
|
logger: logging.Logger | None = None,
|
||||||
|
sleep: Callable[[float], None] = time.sleep,
|
||||||
|
fetch_impl: Callable[[Repo], Callable[[], object]] | None = None,
|
||||||
|
pull_impl: Callable[[Repo], Callable[[], object]] | None = None,
|
||||||
|
) -> Repo:
|
||||||
|
"""Ensure a local working clone of the blog repository exists and is up to date.
|
||||||
|
|
||||||
|
Returns the :class:`git.Repo` pointing at an up-to-date working tree.
|
||||||
|
Raises :class:`BlogRepoError` on irrecoverable failures.
|
||||||
|
"""
|
||||||
|
url = repo_url or blog_repo_url()
|
||||||
|
target = blog_path or blog_dir(data_dir)
|
||||||
|
retries = MAX_RETRIES_DEFAULT if max_retries is None else max_retries
|
||||||
|
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
needs_clone = _is_dir_empty(target)
|
||||||
|
|
||||||
|
if needs_clone:
|
||||||
|
if logger is not None:
|
||||||
|
logger.info(
|
||||||
|
"blog_clone_start",
|
||||||
|
extra={"event": "blog_clone_start", "url": url, "path": str(target)},
|
||||||
|
)
|
||||||
|
cloned = Repo.clone_from(url, str(target))
|
||||||
|
if logger is not None:
|
||||||
|
logger.info(
|
||||||
|
"blog_clone_complete",
|
||||||
|
extra={"event": "blog_clone_complete", "path": str(target)},
|
||||||
|
)
|
||||||
|
return cloned
|
||||||
|
|
||||||
|
try:
|
||||||
|
repo = Repo(str(target))
|
||||||
|
except (InvalidGitRepositoryError, NoSuchPathError) as exc:
|
||||||
|
raise BlogRepoError(
|
||||||
|
f"{target} exists but is not a valid git repository: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
fetch = (fetch_impl or _default_fetch)(repo)
|
||||||
|
pull = (pull_impl or _default_pull)(repo)
|
||||||
|
|
||||||
|
_fetch_and_pull(
|
||||||
|
repo,
|
||||||
|
fetch=fetch,
|
||||||
|
pull=pull,
|
||||||
|
sleep=sleep,
|
||||||
|
max_retries=retries,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
def _default_fetch(repo: Repo) -> Callable[[], object]:
|
||||||
|
def _do() -> object:
|
||||||
|
return repo.remotes[REMOTE_NAME].fetch()
|
||||||
|
return _do
|
||||||
|
|
||||||
|
|
||||||
|
def _default_pull(repo: Repo) -> Callable[[], object]:
|
||||||
|
def _do() -> object:
|
||||||
|
return repo.git.pull("--ff-only")
|
||||||
|
return _do
|
||||||
@@ -22,7 +22,7 @@ REQUIRED_KEYS = (
|
|||||||
"TZ",
|
"TZ",
|
||||||
)
|
)
|
||||||
|
|
||||||
OPTIONAL_KEYS: tuple[str, ...] = ()
|
OPTIONAL_KEYS: tuple[str, ...] = ("BLOG_REPO_URL", "BLOG_DIR")
|
||||||
|
|
||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
"VISIBILITY": "public",
|
"VISIBILITY": "public",
|
||||||
@@ -30,6 +30,8 @@ DEFAULTS = {
|
|||||||
"MAX_RETRIES": "3",
|
"MAX_RETRIES": "3",
|
||||||
"TZ": "Europe/Berlin",
|
"TZ": "Europe/Berlin",
|
||||||
"RUN_AT": "09:00",
|
"RUN_AT": "09:00",
|
||||||
|
"BLOG_REPO_URL": "https://git.chaospott.de/Chaospott/site",
|
||||||
|
"BLOG_DIR": "blog",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +54,8 @@ class Config:
|
|||||||
run_at: str
|
run_at: str
|
||||||
tz: str
|
tz: str
|
||||||
data_dir: Path = field(default_factory=lambda: Path("/app/data"))
|
data_dir: Path = field(default_factory=lambda: Path("/app/data"))
|
||||||
|
blog_repo_url: str = "https://git.chaospott.de/Chaospott/site"
|
||||||
|
blog_dir: Path = field(default_factory=lambda: Path("/app/data/blog"))
|
||||||
extra: dict = field(default_factory=dict)
|
extra: dict = field(default_factory=dict)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -103,6 +107,13 @@ def load_config(dotenv_path: Optional[Path] = None) -> Config:
|
|||||||
|
|
||||||
data_dir = Path(os.environ.get("DATA_DIR", "/app/data")).resolve()
|
data_dir = Path(os.environ.get("DATA_DIR", "/app/data")).resolve()
|
||||||
|
|
||||||
|
blog_dir_raw = Path(merged["BLOG_DIR"]).expanduser()
|
||||||
|
blog_dir = (
|
||||||
|
blog_dir_raw
|
||||||
|
if blog_dir_raw.is_absolute()
|
||||||
|
else (data_dir / blog_dir_raw).resolve()
|
||||||
|
)
|
||||||
|
|
||||||
extra = {k: v for k, v in merged.items() if k not in REQUIRED_KEYS + OPTIONAL_KEYS}
|
extra = {k: v for k, v in merged.items() if k not in REQUIRED_KEYS + OPTIONAL_KEYS}
|
||||||
|
|
||||||
return Config(
|
return Config(
|
||||||
@@ -116,6 +127,8 @@ def load_config(dotenv_path: Optional[Path] = None) -> Config:
|
|||||||
run_at=merged["RUN_AT"],
|
run_at=merged["RUN_AT"],
|
||||||
tz=merged["TZ"],
|
tz=merged["TZ"],
|
||||||
data_dir=data_dir,
|
data_dir=data_dir,
|
||||||
|
blog_repo_url=merged["BLOG_REPO_URL"],
|
||||||
|
blog_dir=blog_dir,
|
||||||
extra=extra,
|
extra=extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -151,6 +164,12 @@ def validate_config(values: dict[str, str]) -> None:
|
|||||||
f"VISIBILITY={visibility!r} must be one of: {sorted(ALLOWED_VISIBILITY)}"
|
f"VISIBILITY={visibility!r} must be one of: {sorted(ALLOWED_VISIBILITY)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
blog_repo_url = values.get("BLOG_REPO_URL", "")
|
||||||
|
if blog_repo_url and not _is_valid_url(blog_repo_url):
|
||||||
|
errors.append(
|
||||||
|
f"BLOG_REPO_URL={blog_repo_url!r} must be a valid http(s) URL"
|
||||||
|
)
|
||||||
|
|
||||||
tz = values.get("TZ", "")
|
tz = values.get("TZ", "")
|
||||||
if tz and not _is_valid_tz(tz):
|
if tz and not _is_valid_tz(tz):
|
||||||
errors.append(f"TZ={tz!r} must be a valid IANA timezone")
|
errors.append(f"TZ={tz!r} must be a valid IANA timezone")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import time
|
|||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
from .blog import BlogRepoError, ensure_repo
|
||||||
from .config import Config, ConfigError, load_config
|
from .config import Config, ConfigError, load_config
|
||||||
from .logging_setup import (
|
from .logging_setup import (
|
||||||
configure_json_logging,
|
configure_json_logging,
|
||||||
@@ -30,6 +31,13 @@ def _run_once(config: Config) -> tuple[int, int, int, int, list[str]]:
|
|||||||
|
|
||||||
Returns ``(scanned, matched, posted, skipped, posted_ids)``.
|
Returns ``(scanned, matched, posted, skipped, posted_ids)``.
|
||||||
"""
|
"""
|
||||||
|
ensure_repo(
|
||||||
|
config.data_dir,
|
||||||
|
repo_url=config.blog_repo_url,
|
||||||
|
blog_path=config.blog_dir,
|
||||||
|
max_retries=config.max_retries,
|
||||||
|
)
|
||||||
|
|
||||||
already_posted = set(load_posted(config.data_dir).keys())
|
already_posted = set(load_posted(config.data_dir).keys())
|
||||||
|
|
||||||
scanned = 0
|
scanned = 0
|
||||||
@@ -96,6 +104,17 @@ def main() -> int:
|
|||||||
|
|
||||||
config.data_dir.mkdir(parents=True, exist_ok=True)
|
config.data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ensure_repo(
|
||||||
|
config.data_dir,
|
||||||
|
repo_url=config.blog_repo_url,
|
||||||
|
blog_path=config.blog_dir,
|
||||||
|
max_retries=config.max_retries,
|
||||||
|
)
|
||||||
|
except BlogRepoError as exc:
|
||||||
|
log_error("blog_repo_error", exc=exc)
|
||||||
|
return 1
|
||||||
|
|
||||||
log_startup(
|
log_startup(
|
||||||
version=__version__,
|
version=__version__,
|
||||||
site_url=config.site_url,
|
site_url=config.site_url,
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from git import GitCommandError, InvalidGitRepositoryError, Repo
|
||||||
|
|
||||||
|
from tenbackward.blog import (
|
||||||
|
BACKOFF_BASE_SECONDS,
|
||||||
|
BlogRepoError,
|
||||||
|
DEFAULT_BLOG_SUBDIR,
|
||||||
|
DEFAULT_REPO_URL,
|
||||||
|
blog_dir,
|
||||||
|
blog_repo_url,
|
||||||
|
ensure_repo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _git_err(stderr: str = "boom") -> GitCommandError:
|
||||||
|
return GitCommandError(["git"], stderr=stderr, status=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_blog_dir_default_subdir(tmp_path: Path) -> None:
|
||||||
|
assert blog_dir(tmp_path) == tmp_path / DEFAULT_BLOG_SUBDIR
|
||||||
|
|
||||||
|
|
||||||
|
def test_blog_repo_url_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.delenv("BLOG_REPO_URL", raising=False)
|
||||||
|
assert blog_repo_url() == DEFAULT_REPO_URL
|
||||||
|
|
||||||
|
|
||||||
|
def test_blog_repo_url_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("BLOG_REPO_URL", "https://example.com/repo.git")
|
||||||
|
assert blog_repo_url() == "https://example.com/repo.git"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_repo_clones_when_missing(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
target = tmp_path / "blog"
|
||||||
|
fake_repo = MagicMock(spec=Repo)
|
||||||
|
calls: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def fake_clone_from(url: str, path: str) -> Repo:
|
||||||
|
calls.append((url, path))
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
(target / ".git").mkdir()
|
||||||
|
return fake_repo
|
||||||
|
|
||||||
|
monkeypatch.setattr("tenbackward.blog.Repo.clone_from", fake_clone_from)
|
||||||
|
sleeps: list[float] = []
|
||||||
|
result = ensure_repo(
|
||||||
|
tmp_path,
|
||||||
|
repo_url="https://example.com/repo.git",
|
||||||
|
blog_path=target,
|
||||||
|
max_retries=2,
|
||||||
|
sleep=sleeps.append,
|
||||||
|
)
|
||||||
|
assert result is fake_repo
|
||||||
|
assert calls == [("https://example.com/repo.git", str(target))]
|
||||||
|
assert sleeps == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_repo_pulls_when_clone_exists(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
target = tmp_path / "blog"
|
||||||
|
target.mkdir()
|
||||||
|
(target / ".git").mkdir()
|
||||||
|
|
||||||
|
fake_repo = MagicMock(spec=Repo)
|
||||||
|
monkeypatch.setattr("tenbackward.blog.Repo", MagicMock(return_value=fake_repo))
|
||||||
|
|
||||||
|
fetch_impl = MagicMock()
|
||||||
|
pull_impl = MagicMock()
|
||||||
|
sleeps: list[float] = []
|
||||||
|
|
||||||
|
result = ensure_repo(
|
||||||
|
tmp_path,
|
||||||
|
repo_url="https://example.com/repo.git",
|
||||||
|
blog_path=target,
|
||||||
|
max_retries=3,
|
||||||
|
sleep=sleeps.append,
|
||||||
|
fetch_impl=lambda r: fetch_impl,
|
||||||
|
pull_impl=lambda r: pull_impl,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is fake_repo
|
||||||
|
fetch_impl.assert_called_once_with()
|
||||||
|
pull_impl.assert_called_once_with()
|
||||||
|
assert sleeps == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_repo_aborts_on_local_modifications(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
target = tmp_path / "blog"
|
||||||
|
target.mkdir()
|
||||||
|
(target / ".git").mkdir()
|
||||||
|
|
||||||
|
fake_repo = MagicMock(spec=Repo)
|
||||||
|
monkeypatch.setattr("tenbackward.blog.Repo", MagicMock(return_value=fake_repo))
|
||||||
|
|
||||||
|
fetch_impl = MagicMock()
|
||||||
|
pull_impl = MagicMock(
|
||||||
|
side_effect=_git_err("Your local changes to 'foo' would be overwritten by merge")
|
||||||
|
)
|
||||||
|
sleeps: list[float] = []
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
with pytest.raises(BlogRepoError, match="local modifications"):
|
||||||
|
ensure_repo(
|
||||||
|
tmp_path,
|
||||||
|
repo_url="https://example.com/repo.git",
|
||||||
|
blog_path=target,
|
||||||
|
max_retries=5,
|
||||||
|
sleep=sleeps.append,
|
||||||
|
fetch_impl=lambda r: fetch_impl,
|
||||||
|
pull_impl=lambda r: pull_impl,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert fetch_impl.call_count == 1
|
||||||
|
assert pull_impl.call_count == 1
|
||||||
|
assert sleeps == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_repo_retries_transient_error_then_succeeds(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
target = tmp_path / "blog"
|
||||||
|
target.mkdir()
|
||||||
|
(target / ".git").mkdir()
|
||||||
|
|
||||||
|
fake_repo = MagicMock(spec=Repo)
|
||||||
|
monkeypatch.setattr("tenbackward.blog.Repo", MagicMock(return_value=fake_repo))
|
||||||
|
|
||||||
|
fetch_impl = MagicMock(side_effect=[_git_err("Could not resolve host"), None])
|
||||||
|
pull_impl = MagicMock()
|
||||||
|
sleeps: list[float] = []
|
||||||
|
|
||||||
|
ensure_repo(
|
||||||
|
tmp_path,
|
||||||
|
repo_url="https://example.com/repo.git",
|
||||||
|
blog_path=target,
|
||||||
|
max_retries=5,
|
||||||
|
sleep=sleeps.append,
|
||||||
|
fetch_impl=lambda r: fetch_impl,
|
||||||
|
pull_impl=lambda r: pull_impl,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert fetch_impl.call_count == 2
|
||||||
|
pull_impl.assert_called_once_with()
|
||||||
|
assert sleeps == [BACKOFF_BASE_SECONDS ** 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_repo_raises_after_exhausted_retries(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
target = tmp_path / "blog"
|
||||||
|
target.mkdir()
|
||||||
|
(target / ".git").mkdir()
|
||||||
|
|
||||||
|
fake_repo = MagicMock(spec=Repo)
|
||||||
|
monkeypatch.setattr("tenbackward.blog.Repo", MagicMock(return_value=fake_repo))
|
||||||
|
|
||||||
|
fetch_impl = MagicMock(side_effect=_git_err("Could not resolve host"))
|
||||||
|
pull_impl = MagicMock()
|
||||||
|
sleeps: list[float] = []
|
||||||
|
|
||||||
|
with pytest.raises(BlogRepoError, match="blog fetch/pull failed"):
|
||||||
|
ensure_repo(
|
||||||
|
tmp_path,
|
||||||
|
repo_url="https://example.com/repo.git",
|
||||||
|
blog_path=target,
|
||||||
|
max_retries=2,
|
||||||
|
sleep=sleeps.append,
|
||||||
|
fetch_impl=lambda r: fetch_impl,
|
||||||
|
pull_impl=lambda r: pull_impl,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert fetch_impl.call_count == 3
|
||||||
|
pull_impl.assert_not_called()
|
||||||
|
assert sleeps == [BACKOFF_BASE_SECONDS ** 1, BACKOFF_BASE_SECONDS ** 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_repo_invalid_clone_dir_raises(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
target = tmp_path / "blog"
|
||||||
|
target.mkdir()
|
||||||
|
(target / "not-a-repo.txt").write_text("hi")
|
||||||
|
|
||||||
|
def fake_repo(_path: str) -> Repo:
|
||||||
|
raise InvalidGitRepositoryError(f"{_path} is not a repo")
|
||||||
|
|
||||||
|
monkeypatch.setattr("tenbackward.blog.Repo", fake_repo)
|
||||||
|
|
||||||
|
with pytest.raises(BlogRepoError, match="not a valid git repository"):
|
||||||
|
ensure_repo(
|
||||||
|
tmp_path,
|
||||||
|
repo_url="https://example.com/repo.git",
|
||||||
|
blog_path=target,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
@@ -35,6 +35,35 @@ def test_load_config_succeeds_with_complete_env(env_setup) -> None:
|
|||||||
assert config.visibility == "public"
|
assert config.visibility == "public"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_blog_defaults(env_setup) -> None:
|
||||||
|
config = load_config()
|
||||||
|
assert config.blog_repo_url == "https://git.chaospott.de/Chaospott/site"
|
||||||
|
assert config.blog_dir.name == "blog"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_blog_repo_url_override(env_setup, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("BLOG_REPO_URL", "https://example.com/repo.git")
|
||||||
|
config = load_config()
|
||||||
|
assert config.blog_repo_url == "https://example.com/repo.git"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_config_rejects_bad_blog_repo_url(env_setup) -> None:
|
||||||
|
values = {
|
||||||
|
"MASTODON_BASE_URL": "https://mastodon.example",
|
||||||
|
"MASTODON_ACCESS_TOKEN": "x",
|
||||||
|
"VISIBILITY": "public",
|
||||||
|
"SITE_URL": "https://blog.example.com",
|
||||||
|
"HASHTAGS": "#throwback",
|
||||||
|
"THROWNBACK_PREFIX": "Throwback:",
|
||||||
|
"MAX_RETRIES": "3",
|
||||||
|
"RUN_AT": "09:00",
|
||||||
|
"TZ": "Europe/Berlin",
|
||||||
|
"BLOG_REPO_URL": "ftp://bad",
|
||||||
|
}
|
||||||
|
with pytest.raises(ConfigError, match="BLOG_REPO_URL"):
|
||||||
|
validate_config(values)
|
||||||
|
|
||||||
|
|
||||||
def test_validate_config_lists_every_missing_key(env_setup, monkeypatch) -> None:
|
def test_validate_config_lists_every_missing_key(env_setup, monkeypatch) -> None:
|
||||||
monkeypatch.delenv("MASTODON_ACCESS_TOKEN")
|
monkeypatch.delenv("MASTODON_ACCESS_TOKEN")
|
||||||
monkeypatch.delenv("SITE_URL")
|
monkeypatch.delenv("SITE_URL")
|
||||||
|
|||||||
Reference in New Issue
Block a user