AI Implementation feature(1083): Environment Configuration and Logging Baseline #2

Merged
m0rph3us1987 merged 2 commits from feature-1083-1785865383249 into staging 2026-08-04 17:53:19 +00:00
8 changed files with 762 additions and 0 deletions
Showing only changes of commit bbad70bed1 - Show all commits
+103
View File
@@ -0,0 +1,103 @@
---
type: api
title: Config Schema
description: Required/optional environment variables, validation rules, and the typed Config dataclass returned by tenbackward.config.load_config.
tags: [config, env, schema]
timestamp: 2026-08-04T17:51:00Z
---
# Purpose
`tenbackward.config.load_config()` is the single boundary that turns
process environment + optional `.env` file into a typed `Config`
dataclass. Job 1083 changes every variable that ships in
`.env.example` so that it is **explicitly required** at every layer
(`entrypoint.sh`, `config.validate_config`, and the rendered cron).
# Required Environment Variables
All eight of these must be present and non-empty in the process
environment for the container to boot.
| Key | Purpose | Validation |
|----------------------|--------------------------------------------------------|------------------------------------------------------------------|
| `MASTODON_BASE_URL` | Mastodon instance to post against. | Must parse with `http` or `https` scheme and a non-empty netloc. |
| `MASTODON_ACCESS_TOKEN` | OAuth token used by the future Mastodon.py client. | Required string. |
| `VISIBILITY` | Default post visibility. | Must be `public` or `unlisted`. |
| `SITE_URL` | Source blog URL (used by the future clone step). | Must parse with `http` or `https` scheme and a non-empty netloc. |
| `HASHTAGS` | Comma-separated hashtags appended to every throwback. | Required string (formatting handled by the future poster). |
| `THROWNBACK_PREFIX` | Per-post title prefix (default: `Throwback:`). | Required string. |
| `MAX_RETRIES` | Number of additional retries after the first attempt. | Non-negative integer (parsed by `_parse_max_retries`). |
| `RUN_AT` | Daily fire time for the cron entry. | `HH:MM` 24-hour format. |
| `TZ` | IANA timezone used by cron + the container clock. | Must resolve via `zoneinfo.ZoneInfo`. |
> **Note** — All nine keys are required since Job 1083. The previously
> optional `MASTODON_VISIBILITY`, `THROWBACK_PREFIX`, and `RETRY_COUNT`
> were promoted to first-class citizens and renamed to `VISIBILITY`,
> `THROWNBACK_PREFIX`, and `MAX_RETRIES`.
# Defaults
`DEFAULTS` in `config.py` provides fallback strings that `apply_defaults`
fills into the merged map **before** validation runs:
| Default key | Default value |
|-----------------------|-------------------|
| `VISIBILITY` | `public` |
| `THROWNBACK_PREFIX` | `Throwback:` |
| `MAX_RETRIES` | `3` |
| `TZ` | `Europe/Berlin` |
| `RUN_AT` | `09:00` |
Because every required key carries a default, a freshly-initialised
container can still boot to validate the scaffold, but the operator
must supply `MASTODON_BASE_URL` and `MASTODON_ACCESS_TOKEN` (and
ideally `SITE_URL`) to make a real run.
# `Config` Dataclass
| Field | Type | Source |
|-------------------------|-----------|----------------------------------------------|
| `mastodon_base_url` | `str` | `MASTODON_BASE_URL` |
| `mastodon_access_token` | `str` | `MASTODON_ACCESS_TOKEN` |
| `visibility` | `str` | `VISIBILITY` |
| `site_url` | `str` | `SITE_URL` |
| `hashtags` | `str` | `HASHTAGS` |
| `throwback_prefix` | `str` | `THROWNBACK_PREFIX` |
| `max_retries` | `int` | `MAX_RETRIES` (parsed as non-negative int) |
| `run_at` | `str` | `RUN_AT` |
| `tz` | `str` | `TZ` |
| `data_dir` | `Path` | Constant: `Path("/app/data")` |
# Validation Behaviour
`validate_config(values)` collects all failures before raising so the
operator sees every problem at once. A single `ConfigError` lists each
problem joined with `"; "`. Validation covers:
* Required keys are non-empty.
* `RUN_AT` parses as `HH:MM`.
* `MASTODON_BASE_URL` and `SITE_URL` parse via `urllib.parse.urlparse`
with an `http`/`https` scheme and a non-empty `netloc`.
* `VISIBILITY` is one of `{"public", "unlisted"}` (`ALLOWED_VISIBILITY`).
* `TZ` resolves via `zoneinfo.ZoneInfo`.
* `MAX_RETRIES` parses as a non-negative integer (rejects bools).
# Exit Codes
| Code | Source | Meaning |
|------|-----------------------------------------|----------------------------------------------------------------------|
| `1` | `main.main()` (retry budget exhausted) | Pipeline failed on every attempt after `MAX_RETRIES + 1` tries. |
| `2` | `main.main()` (`ConfigError`) | Configuration was present but invalid; details are logged. |
# Citations
* [1] `src/tenbackward/config.py``REQUIRED_KEYS`, `DEFAULTS`, `ALLOWED_VISIBILITY`, `Config`, `load_config`, `validate_config`.
* [2] `.env.example` — canonical env-var list.
* [3] `entrypoint.sh` — shell-side mirror of the required keys.
# Related
* [System Architecture](/architecture/system-overview.md)
* [Logging & Run Summary](/architecture/logging.md)
* [Environment Variable Setup](/operations/environment-setup.md)
+125
View File
@@ -0,0 +1,125 @@
---
type: architecture
title: Logging & Run Summary
description: Structured JSON logging in 10Backward — JsonFormatter, secret redaction, and the startup / error / run_complete event helpers.
tags: [logging, observability, json]
timestamp: 2026-08-04T17:51:00Z
---
# Purpose
`tenbackward.logging_setup` replaces the scaffold-era `basicConfig`
text logger with a **single-line JSON formatter** that downstream
log shippers can index. It also owns the three helpers the runner
uses to record structured events: `log_startup`, `log_error`,
`log_run_summary`.
# JsonFormatter
`JsonFormatter.format(record)` returns one JSON object per log record:
| Key | Source |
|------------|---------------------------------------------------------------------|
| `ts` | UTC `datetime.fromtimestamp(record.created).isoformat()`. |
| `level` | `record.levelname` (e.g. `INFO`, `ERROR`). |
| `logger` | `record.name` (always `tenbackward`). |
| `message` | `record.getMessage()`. |
| _extras_ | Any keyword passed via `logging.info(..., extra={...})` that is not a reserved `LogRecord` attribute and whose key is **not** in `_SECRET_KEYS`. |
| `exc_type` | When `record.exc_info` is set, the exception class name. |
Reserved `LogRecord` attributes and any key starting with `_` are
dropped before serialization, so the payload contains only the values
that callers intentionally attached.
## Secret Redaction
`_SECRET_KEYS` is the canonical denylist applied in two places:
1. **At format time** for keys passed via `extra={...}`.
2. **At helper time** by `_redact_dict(...)` so values reaching
`log_startup` / `log_error` never expose known-sensitive fields
even before they hit the formatter.
Deny-list contents (case-insensitive):
```
token, access_token, mastodon_access_token, password, secret,
authorization, api_key
```
Any matching key is replaced with the string `[REDACTED]`.
# Helper Functions
| Helper | When it fires | Logger / Level | `event` field |
|---------------------------------------|-----------------------------------------------------------------------|----------------|--------------------|
| `configure_json_logging(level=INFO)` | First line of `main.main()`. Idempotent — clears existing handlers. | n/a | n/a |
| `log_startup(**fields)` | After config load + `data_dir.mkdir`, before the pipeline pass. | `INFO` | `startup` |
| `log_error(event, *, exc=None, **fields)` | When a pipeline attempt raises, or when the retry budget is exhausted. | `ERROR` | the supplied event name; `exc_type` added when `exc` is provided. |
| `log_run_summary(scanned, matched, posted, skipped, posted_ids)` | Once per successful pass. **Silently skipped** when all counters are zero and no ids were posted. | `INFO` | `run_complete` |
## Startup payload
`log_startup` is invoked with:
```
version, site_url, run_at, tz, hashtags, throwback_prefix
```
Each is added to the `startup` event payload under its own key.
## Run-summary payload
`log_run_summary` is the canonical "what did the bot do today?" record:
```json
{
"ts": "2026-08-04T09:00:00+00:00",
"level": "INFO",
"logger": "tenbackward",
"message": "run complete",
"event": "run_complete",
"scanned": 0,
"matched": 0,
"posted": 0,
"skipped": 0,
"posted_ids": []
}
```
A pass with no candidates emits **no line at all**, matching the
"silent on no matches" contract.
# Examples
## Successful run with one new post
```json
{"ts":"2026-08-04T09:00:00+00:00","level":"INFO","logger":"tenbackward","message":"startup","event":"startup","version":"0.1.0","site_url":"https://blog.example.com","run_at":"09:00","tz":"Europe/Berlin","hashtags":"#throwback,#10backward","throwback_prefix":"Throwback:"}
{"ts":"2026-08-04T09:00:01+00:00","level":"INFO","logger":"tenbackward","message":"run complete","event":"run_complete","scanned":1,"matched":1,"posted":1,"skipped":0,"posted_ids":["2025-08-04-post-slug"]}
```
## Retry budget exhausted
```json
{"ts":"...","level":"ERROR","logger":"tenbackward","message":"pipeline_error","event":"pipeline_error","attempt":1,"max_attempts":4,"exc_type":"ConnectionError"}
{"ts":"...","level":"ERROR","logger":"tenbackward","message":"pipeline_error","event":"pipeline_error","attempt":2,"max_attempts":4,"exc_type":"ConnectionError"}
{"ts":"...","level":"ERROR","logger":"tenbackward","message":"pipeline_failed","event":"pipeline_failed","attempts":4,"exc_type":"ConnectionError"}
```
The container then exits with status `1`.
## Token accidentally logged
Even if a future call wrote `extra={"access_token": "..."}`, the
formatter replaces it:
```json
{ "...": "...", "access_token": "[REDACTED]" }
```
# Related
* [System Architecture](/architecture/system-overview.md)
* [Pipeline Runner](/architecture/pipeline-runner.md)
* [Config Schema](/architecture/config-schema.md)
+111
View File
@@ -0,0 +1,111 @@
---
type: architecture
title: Pipeline Runner
description: How tenbackward.main turns one cron tick into a startup log line, a retry-wrapped pipeline pass, and a single run_complete summary line.
tags: [pipeline, runner, retry]
timestamp: 2026-08-04T17:51:00Z
---
# Purpose
`tenbackward.main` is the entry point executed by cron. Job 1083
rewrote it to: (a) install JSON logging, (b) execute a retry-wrapped
pipeline pass that is **silent when there are no candidates**, and
(c) emit a single structured `run_complete` summary on success.
# Call Flow
```
main.main()
├── configure_json_logging()
├── load_config() ── raises ConfigError → exit 2
├── config.data_dir.mkdir(parents=True, exist_ok=True)
├── log_startup(version, site_url, run_at, tz, hashtags, throwback_prefix)
├── result = _run_with_retry(config)
│ ├── for attempt in 1 .. max_retries+1:
│ │ try: return _run_once(config)
│ │ except Exception as exc:
│ │ log_error("pipeline_error", exc=exc, attempt=attempt, max_attempts=attempts)
│ ├── log_error("pipeline_failed", exc=last_exc, attempts=attempts)
│ └── return None
├── if result is None: return 1
└── log_run_summary(scanned, matched, posted, skipped, posted_ids)
```
# Extension Point: `_iter_candidates`
```python
def _iter_candidates(config: Config) -> Iterable[str]: ...
```
Job 1083 ships `_iter_candidates` as an **empty iterator** so the
silent-on-no-matches contract is the default behaviour. The future
blog-clone + matching pipeline plugs into this function without
touching `_run_once` or `_run_with_retry`.
# `_run_once` — Summary Counters
`_run_once` returns a 5-tuple:
```python
(scanned, matched, posted, skipped, posted_ids)
```
| Counter | Meaning |
|-------------|----------------------------------------------------------------------|
| `scanned` | Items yielded by `_iter_candidates`. |
| `matched` | Items that survived the dedupe-vs-`already_posted` check. |
| `posted` | Items newly recorded in `posted.json` during this run. |
| `skipped` | Items that matched but were already in `posted.json` (deduped). |
| `posted_ids`| The list of post identifiers written to state. |
`state.save_posted` is called **only** when `posted_ids` is non-empty,
so a no-op run does not touch `posted.json` on disk.
# Retry Behaviour (`_run_with_retry`)
* `attempts = max(1, config.max_retries + 1)` — at least one attempt
even if `MAX_RETRIES=0`.
* Any exception inside `_run_once` is caught (`noqa: BLE001`
intentional, the whole pass is opaque to the runner).
* Each failed attempt is recorded via
`log_error("pipeline_error", exc=exc, attempt=attempt, max_attempts=attempts)`.
* When the budget is exhausted, the runner emits
`log_error("pipeline_failed", exc=last_exc, attempts=attempts)` and
returns `None` so `main` can translate it to `exit 1`.
* `time.sleep(0)` between attempts is the placeholder seam where the
next job can introduce real backoff.
# Silent-on-No-Matches Contract
A run with zero candidates produces exactly one line:
```json
{"ts":"...","level":"INFO","message":"startup","event":"startup",...}
```
No `run_complete` line is emitted. Tests assert this by counting log
records (`tests/test_run_logging.py::test_run_summary_silent_when_no_candidates`).
# Example Sequence (Healthy Run)
1. `startup` line at INFO.
2. `_run_with_retry` returns `(0, 0, 0, 0, [])` (no candidates yet
because `_iter_candidates` is empty) → `main` exits `0`
**without** a `run_complete` line.
3. Next-day tick repeats the cycle.
# Example Sequence (Future Blog Step in Place)
1. `startup` line at INFO.
2. `_iter_candidates` yields `[id-1, id-2]`. `id-1` is already in
`posted.json`, so `skipped += 1`. `id-2` is new and appended to
`posted_ids` and persisted.
3. `run_complete` line at INFO with
`scanned=2, matched=2, posted=1, skipped=1, posted_ids=["id-2"]`.
# Related
* [Logging & Run Summary](/architecture/logging.md)
* [Config Schema](/architecture/config-schema.md)
* [Cron Lifecycle](/operations/cron-lifecycle.md)
+98
View File
@@ -0,0 +1,98 @@
---
type: architecture
title: System Architecture
description: Component map of 10Backward — how config, logging, the pipeline runner, state persistence, and container entrypoint are wired together.
tags: [architecture, overview]
timestamp: 2026-08-04T17:51:00Z
---
# Overview
`10Backward` is a Mastodon daily-throwback bot that runs as a single
containerised cron job. Job 1083 adds a structured logging layer and a
retry-capable pipeline runner on top of the scaffold shipped by Job 1082.
The container boots, validates environment configuration, renders a
`/etc/cron.d/tenbackward` entry that fires once per day at the
configured `RUN_AT`, and then runs `python -m tenbackward` in the
foreground of the `cron` process. Each scheduled invocation calls
`tenbackward.main.main()`, which:
1. Installs the JSON logging formatter on the root logger.
2. Loads and validates the `Config`.
3. Ensures the data directory exists.
4. Emits a `startup` log line.
5. Executes one pipeline pass wrapped in a retry loop.
6. Emits a `run_complete` summary (skipped silently if there were no candidates).
# Components
| Component | Responsibility |
|--------------------------|------------------------------------------------------------------------------------------------------------|
| `entrypoint.sh` | Validates required env vars, renders the cron file from `RUN_AT`/`TZ`, then `exec cron -f`. |
| `tenbackward.main` | CLI entry point; orchestrates startup logging, retry-wrapped pipeline pass, and run summary. |
| `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.state` | Read/write of `posted.json` (atomic temp-file replace). |
| `tenbackward.blog` | Stubbed blog clone/update helper (lands in a follow-up job). |
| `/etc/cron.d/tenbackward`| Rendered cron file. One daily line that `cd /app` and runs `python -m tenbackward`. |
# Communication & Wiring
```
+-------------------+ env vars +-------------------------+
| entrypoint | -------------------> | config.load_config |
| (.sh) | | (Config dataclass) |
+---------+---------+ +-----------+-------------+
| |
| installs cron file | feeds
v v
+-------------------+ +-------------------------+
| /etc/cron.d/ | -- daily fires --> | tenbackward.main.main |
| tenbackward | | - configure logging |
+-------------------+ | - retry-wrapped pass |
+-----------+-------------+
|
v
+-------------------------+
| _run_with_retry(...) |
| -> _run_once(...) |
| -> state.{load,save}|
+-------------------------+
```
* **Env → Config.** `load_config()` merges a `.env` file (when
present) under the process environment via `dotenv_values` + `load_dotenv`,
applies defaults, then validates the merged map before producing the
`Config` dataclass.
* **Cron → main.** Each scheduled tick re-runs `python -m tenbackward`,
so every run is a fresh interpreter invocation.
* **Pipeline state.** `_run_once` reads `posted.json` via
`state.load_posted()` for dedupe, then writes it back via
`state.save_posted()` only when at least one new post was recorded.
# Key Files
| Path | Responsibility |
|-------------------------------------|---------------------------------------------------------------------------------|
| `/repo/entrypoint.sh` | Bootstraps cron; validates required env vars; renders `/etc/cron.d/tenbackward`. |
| `/repo/src/tenbackward/main.py` | CLI entry, retry wrapper, pipeline counters. |
| `/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/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/.env.example` | Canonical list of environment variables. |
| `/repo/docker-compose.yml` | Service definition; binds env vars from the host `.env`. |
| `/repo/Dockerfile` | Builds the runtime image. |
| `/repo/crontab/tenbackward.cron` | Cron template shipped in the image. |
| `/repo/crontab/install-cron.sh` | Installs the rendered cron file with mode 0644; refuses placeholder leftovers. |
| `/repo/tests/` | Pytest suite covering config, logging, runner, cron, and entrypoint validation. |
# Related
* [Config Schema](/architecture/config-schema.md)
* [Logging & Run Summary](/architecture/logging.md)
* [Pipeline Runner](/architecture/pipeline-runner.md)
* [Daily Run Guide](/guides/daily-run.md)
* [Environment Variable Setup](/operations/environment-setup.md)
* [Cron Lifecycle](/operations/cron-lifecycle.md)
+118
View File
@@ -0,0 +1,118 @@
---
type: guide
title: Daily Run Guide
description: Operator- and tester-focused walkthrough of one daily cron tick — startup, pipeline pass, silent-on-no-matches, and retry behaviour.
tags: [guide, run, daily, tester]
timestamp: 2026-08-04T17:51:00Z
---
# Purpose
A tester or operator needs to know **what should happen** each time
the cron tick fires. This guide describes the contract from the
outside of the container, in observable terms: log lines, exit codes,
files appearing on the volume, and the "silent on no matches"
behaviour that the runner enforces.
# Triggering a Run Manually
```bash
docker compose exec tenbackward /usr/local/bin/python -m tenbackward
```
This is the same command the container's cron entry issues once per
day at `RUN_AT`. Use it to verify behaviour without waiting for the
scheduled tick.
# What You Should See
The bot uses a **JSON-per-line** logger that writes to
`/app/data/cron.log` (because the cron line redirects with
`>> /app/data/cron.log 2>&1`). Inspecting the file with
`docker compose exec tenbackward tail -n 100 /app/data/cron.log`
should show output similar to:
```json
{"ts": "2026-08-04T09:00:00+00:00", "level": "INFO", "logger": "tenbackward", "message": "startup", "event": "startup", "version": "0.1.0", "site_url": "https://blog.example.com", "run_at": "09:00", "tz": "Europe/Berlin", "hashtags": "#throwback,#10backward", "throwback_prefix": "Throwback:"}
```
On the current scaffold (`_iter_candidates` is intentionally empty),
this is the **only** line you should see — the runner emits no
`run_complete` when no candidates were found. This is the
"silent on no matches" contract.
# Expected Container Behaviour
| Scenario | Lines in `cron.log` | Container exit |
|-------------------------------------------|----------------------------------------------------------------|------------------|
| Config valid, zero candidates (scaffold) | 1× `startup` | `0` |
| Config valid, future blog step posts N≥1 | 1× `startup`, then 1× `run_complete` with `posted=N` | `0` |
| Pipeline raises once, recovers | 1× `pipeline_error`, then 1× `run_complete` | `0` |
| Pipeline keeps raising (budget exhausted) | `MAX_RETRIES + 1` × `pipeline_error`, then 1× `pipeline_failed` | `1` |
| Config invalid | 1× `configuration_error` (extras describe what failed) | `2` |
> **Tester tip** — `docker compose ps` should report the container as
> `running` after a `pipeline_failed` exit *only* if cron has not yet
> fired again. A single failed pipeline tick does not kill the container
> itself; cron re-runs it the next day.
# Files the Tester Should Look For
| Path (inside container) | When it appears |
|-------------------------|--------------------------------------------|
| `/app/data/cron.log` | Always (cron appends stdout/stderr here). |
| `/app/data/posted.json` | Created on first persist; only rewritten when a new id is posted. The scaffold run does **not** create this file. |
To verify these from the host:
```bash
docker compose exec tenbackward ls -la /app/data
docker compose exec tenbackward cat /app/data/cron.log
```
# Visual Elements
This is a server-side bot, so there is no UI. The "UI" consists of:
* **Log lines on stdout** — one JSON object per `INFO`/`ERROR` event.
* **Exit code** — `0` (healthy), `1` (pipeline exhausted), or `2` (config).
* **`posted.json`** — JSON state file; its mtime changes whenever a new id is persisted.
# Examples
## Healthy scaffold tick
```bash
$ docker compose exec tenbackward python -m tenbackward
{"ts":"...","event":"startup",...}
$ echo $?
0
```
## Bad `RUN_AT`
```bash
$ RUN_AT=25:99 docker compose exec tenbackward python -m tenbackward
ERROR: RUN_AT='25:99' must be in HH:MM (24-hour) format
$ docker compose ps
tenbackward Exit 1
```
(The error message lands on stderr in the entrypoint path and is
captured by `cron.log`.)
## Missing required env var
Unset `MAX_RETRIES`, restart the container:
```bash
$ docker compose up --build
tenbackward | ERROR: missing required environment variable(s): MAX_RETRIES
tenbackward exited with code 1
```
# Related
* [Environment Variable Setup](/operations/environment-setup.md)
* [Pipeline Runner](/architecture/pipeline-runner.md)
* [Logging & Run Summary](/architecture/logging.md)
+16
View File
@@ -0,0 +1,16 @@
---
okf_version: "0.1"
---
# Architecture
* [System Architecture](/architecture/system-overview.md) — Component map of 10Backward: entrypoint, config, logging, runner, state, and cron wiring.
* [Config Schema](/architecture/config-schema.md) — Required/optional env vars, validation rules, and the typed Config dataclass.
* [Logging & Run Summary](/architecture/logging.md) — JSON formatter, secret redaction, and structured event helpers.
* [Pipeline Runner](/architecture/pipeline-runner.md) — How a cron tick becomes startup + retry-wrapped pass + run_complete.
# Operations
* [Environment Variable Setup](/operations/environment-setup.md) — Required env vars, renamed keys, and how to populate `.env`.
* [Cron Lifecycle](/operations/cron-lifecycle.md) — How `entrypoint.sh` renders `/etc/cron.d/tenbackward` and hands off to `cron -f`.
# User Guides
* [Daily Run Guide](/guides/daily-run.md) — Tester/operator walkthrough of a manual daily cron tick and expected log/output behaviour.
+96
View File
@@ -0,0 +1,96 @@
---
type: operations
title: Cron Lifecycle
description: How entrypoint.sh validates env, renders /etc/cron.d/tenbackward from RUN_AT/TZ, and hands off to cron -f.
tags: [cron, entrypoint, ops]
timestamp: 2026-08-04T17:51:00Z
---
# Purpose
`entrypoint.sh` is the first process inside the container. Its job is
to refuse to boot when something is wrong with env config and to
materialise a cron file from the templated values.
# Sequence
1. Set `DEBIAN_FRONTEND=noninteractive`.
2. Set `TZ` (default `Europe/Berlin`) and link `/etc/localtime` if
`/usr/share/zoneinfo/${TZ}` exists and `/etc/localtime` does not.
3. Iterate over `required_vars`. Any empty variable aborts with
`ERROR: missing required environment variable(s): ...` and exits
`1`. The current required set is listed in
[Environment Variable Setup](/operations/environment-setup.md).
4. Validate `RUN_AT` against
`^([01][0-9]|2[0-3]):[0-5][0-9]$`. Invalid input exits `1` with
`ERROR: RUN_AT='X' must be in HH:MM (24-hour) format`.
5. Render `/etc/cron.d/tenbackward` into a `mktemp` file with:
```cron
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
TZ=${TZ}
${minute} ${hour} * * * cd /app && /usr/local/bin/python -m tenbackward >> /app/data/cron.log 2>&1
```
6. Install the rendered file with mode `0644`, root:root.
7. Run `crontab/install-cron.sh /etc/cron.d/tenbackward`. The helper
refuses files that still contain the `__RUN_AT__` placeholder and
files that do not start with an `^[A-Z_]+=` cron env line.
8. Print a one-line confirmation: `tenbackward: starting cron
(RUN_AT=..., TZ=...)`.
9. `exec cron -f`.
# Output Files
| Path | Owner / Mode | Purpose |
|------------------------------|--------------|------------------------------------------------------|
| `/etc/cron.d/tenbackward` | root / 0644 | The rendered cron file. |
| `/app/data/cron.log` | container user / append | `python -m tenbackward` stdout+stderr. |
| `/app/data/posted.json` | container user / rw | Persisted state from `tenbackward.state`. |
# Cron Helpers
| File | Purpose |
|---------------------------------|------------------------------------------------------------------------|
| `crontab/tenbackward.cron` | Static template (env + PATH) shipped in the image. |
| `crontab/install-cron.sh` | Validates and installs a rendered cron file with mode 0644. |
# Operator Recipes
* **Trigger a manual run** without waiting for the cron tick:
```bash
docker compose exec tenbackward /usr/local/bin/python -m tenbackward
```
* **Inspect the rendered cron file** (the file the container actually
installed):
```bash
docker compose exec tenbackward cat /etc/cron.d/tenbackward
```
* **Tail the structured logs** produced by the bot:
```bash
docker compose exec tenbackward tail -n 100 /app/data/cron.log
```
Expect one JSON object per line (`startup`, optional `run_complete`,
or `pipeline_error` / `pipeline_failed`).
# Build Script Notes
`build.sh` now `cd`s into its own script directory (so it works
no matter where it is invoked from) and, when possible, symlinks
itself to `/usr/local/bin/build.sh` so the build can also be run as a
plain `build.sh` command inside the build context. The symlink is a
best-effort `|| true`, so a read-only filesystem will not break the
build.
# Related
* [Environment Variable Setup](/operations/environment-setup.md)
* [Pipeline Runner](/architecture/pipeline-runner.md)
* [Logging & Run Summary](/architecture/logging.md)
+95
View File
@@ -0,0 +1,95 @@
---
type: operations
title: Environment Variable Setup
description: How to provide and validate the required env vars for the 10Backward container — .env, docker-compose, and the runtime check in entrypoint.sh.
tags: [env, ops, setup]
timestamp: 2026-08-04T17:51:00Z
---
# Purpose
Job 1083 promoted every previously-optional knob in `.env.example`
to required, and renamed three of them. Operators must populate the
full set before the container will boot.
# Required Variables
| Variable | Example value | Purpose |
|--------------------------|--------------------------------|---------------------------------------------------|
| `MASTODON_BASE_URL` | `https://mastodon.social` | Mastodon instance to post against. |
| `MASTODON_ACCESS_TOKEN` | _(from your Mastodon account)_ | OAuth access token. |
| `VISIBILITY` | `public` | Post visibility (`public` or `unlisted`). |
| `SITE_URL` | `https://blog.example.com` | Source blog URL (used by the future clone step). |
| `HASHTAGS` | `#throwback,#10backward` | Hashtags appended to every throwback post. |
| `THROWNBACK_PREFIX` | `Throwback:` | Prefix prepended to every post. |
| `MAX_RETRIES` | `3` | Non-negative retry count for the pipeline. |
| `RUN_AT` | `09:00` | Daily fire time (HH:MM, 24-hour). |
| `TZ` | `Europe/Berlin` | IANA timezone for cron + container clock. |
# Where Each Name Is Enforced
* `entrypoint.sh` — exports `TZ`, validates all nine are non-empty,
and validates `RUN_AT` matches `^([01][0-9]|2[0-3]):[0-5][0-9]$`
before writing the cron file. A missing var aborts the container
with `ERROR: missing required environment variable(s): ...` and
exits `1`.
* `tenbackward.config.validate_config` — same set, plus URL/visibility/
TZ/MAX_RETRIES validation. Failures surface as a single
`ConfigError` listing every problem.
* The `kilo.json` config file is **not** an env-var file — it is the
agent runtime configuration.
# Setup Steps
1. Copy the template:
```bash
cp .env.example .env
```
2. Replace placeholder values in `.env` (the example ships with
`MASTODON_ACCESS_TOKEN=replace-me` and `https://mastodon.example`).
3. Verify the file parses by running the validator directly:
```bash
python -m tenbackward
```
A successful validation run emits one `startup` JSON log line and
exits `0`. Missing/invalid config exits `2`.
4. Bring up the container:
```bash
docker compose up --build
```
The required-var check in `entrypoint.sh` runs first; if any
variable is empty, the container exits before `cron` starts.
# Renames vs. Job 1082
| Old key | New key |
|------------------------|--------------------------|
| `MASTODON_VISIBILITY` | `VISIBILITY` |
| `THROWBACK_PREFIX` | `THROWNBACK_PREFIX` |
| `RETRY_COUNT` | `MAX_RETRIES` |
If you have an existing `.env` from the scaffold, rename these
manually. The renamed variables are **not** backwards compatible — the
container will refuse to start with both versions set.
# Defaults That Can Be Removed
`DEFAULTS` provides fallbacks for `VISIBILITY`, `THROWNBACK_PREFIX`,
`MAX_RETRIES`, `TZ`, and `RUN_AT`. You may leave them out of your
`.env`, but the operator contract is "every required key is set" —
prod deployments should set them explicitly so a missing key is
caught at boot instead of silently used as a default.
# Related
* [Config Schema](/architecture/config-schema.md)
* [System Architecture](/architecture/system-overview.md)
* [Cron Lifecycle](/operations/cron-lifecycle.md)