Two follow-ups from the post-cutover diagnosis: 1. version_gate: forgejo → local git fallback. If forgejo refresh returns empty AND a local repo path is configured, retry against `git log` from the local checkout. The receiver service runs on the same Pi as forgejo, so a simultaneous restart used to leave the gate's cache empty and reject every PUT with not-in-window. Auto-detects /opt/cis490/.git when the operator hasn't set local_repo_path explicitly — that path is always present on a production receiver and ProtectSystem=strict still allows reads. Logs `source=git-fallback` so this isn't silent. 2. shipper/queue: sweep orphaned outbox tarballs. The lifecycle invariant is `outbox/<id>.tar.zst exists ⇒ episodes/<id>/ exists` — broken historically by the now-fixed fatal-loop, by operator `rm` of an episode dir, or by an OS crash between rename(2) and the post-ship cleanup. Without sweeping, dead bytes pile up forever. New _sweep_outbox runs at the start of every scan, bounded by the file count in outbox/. Tests cover: fallback fires when forgejo unreachable + repo_path set; no fallback when repo_path None (opt-in); orphan tarball + partial get swept on the next pass; live tarballs untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
70 lines
2.1 KiB
Python
70 lines
2.1 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import uvicorn
|
|
|
|
from .app import make_app
|
|
from .config import ReceiverConfig
|
|
from .store import EpisodeStore
|
|
from .version_gate import VersionGate
|
|
|
|
|
|
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,
|
|
)
|
|
version_gate = None
|
|
if cfg.version_gate_enabled:
|
|
# Auto-detect /opt/cis490/.git as a fallback so a forgejo blip
|
|
# at startup doesn't reject every PUT with not-in-window. The
|
|
# receiver service has read access to /opt under
|
|
# ProtectSystem=strict, and that path is where the production
|
|
# install lands — so it's the natural local source of truth.
|
|
repo_path = cfg.version_gate_local_repo
|
|
if repo_path is None and Path("/opt/cis490/.git").is_dir():
|
|
repo_path = Path("/opt/cis490")
|
|
version_gate = VersionGate(
|
|
repo_path=repo_path,
|
|
window=cfg.version_gate_window,
|
|
forgejo_url=cfg.version_gate_forgejo_url,
|
|
repo_owner=cfg.version_gate_repo_owner,
|
|
repo_name=cfg.version_gate_repo_name,
|
|
branch=cfg.version_gate_branch,
|
|
auth_token=cfg.version_gate_auth_token,
|
|
)
|
|
app = make_app(
|
|
store=store,
|
|
max_episode_bytes=cfg.max_episode_bytes,
|
|
bearer_token=cfg.bearer_token,
|
|
version_gate=version_gate,
|
|
)
|
|
uvicorn.run(
|
|
app,
|
|
host=cfg.listen_host,
|
|
port=cfg.listen_port,
|
|
log_config=None,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|