Implements docs/transport.md as a small Starlette app. The receiver streams
episode tarballs to disk, verifies sha256 against an X-Content-SHA256 header,
atomically renames into the store on success, and appends one row to a flat
index.jsonl. No DB. Idempotent re-PUTs return 200; conflicting bodies return
409. Optional bearer-token auth (mTLS terminates at Caddy in prod).
receiver/
store.py EpisodeStore: sha-verifying streaming ingest, atomic rename,
append-only index. No HTTP.
app.py make_app(): Starlette routes + bearer guard.
config.py ReceiverConfig.load(): TOML parser.
__main__.py uvicorn entrypoint, reads --config TOML.
tests/test_receiver.py — 13 tests via httpx.ASGITransport. Covers: 201 new,
200 idempotent replay, 409 conflict, 400 sha mismatch + cleanup, 400 missing/
short header, 400 bad id, 400 bad suffix, 413 too large, 401 bearer enforcement,
schema-version pass-through.
etc/cis490-receiver.service — systemd unit with hardening flags.
etc/receiver.toml.example — config template matching docs/deploy.md.
End-to-end smoke-tested with curl: 201 → 200 → 409 path verified, file
on disk, single index row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
48 lines
1.1 KiB
Python
48 lines
1.1 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import logging
|
|
import os
|
|
|
|
import uvicorn
|
|
|
|
from .app import make_app
|
|
from .config import ReceiverConfig
|
|
from .store import EpisodeStore
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(prog="cis490-receiver")
|
|
parser.add_argument(
|
|
"--config",
|
|
default=os.environ.get("CIS490_RECEIVER_CONFIG", "/etc/cis490/receiver.toml"),
|
|
help="path to receiver TOML config",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
)
|
|
|
|
cfg = ReceiverConfig.load(args.config)
|
|
store = EpisodeStore(
|
|
store_root=cfg.store_root,
|
|
incoming_root=cfg.incoming_root,
|
|
index_path=cfg.index_path,
|
|
)
|
|
app = make_app(
|
|
store=store,
|
|
max_episode_bytes=cfg.max_episode_bytes,
|
|
bearer_token=cfg.bearer_token,
|
|
)
|
|
uvicorn.run(
|
|
app,
|
|
host=cfg.listen_host,
|
|
port=cfg.listen_port,
|
|
log_config=None,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|