- db.py: migrations + DAOs for tokens / projects / jobs / findings (SQLite WAL)
- auth.py: SHA-256 bearer hashing + LAN-CIDR allowlist + admin/app token tiers
- models.py: Pydantic shapes (Project, Subproject, Schedule, Notify, Job, CreateJobRequest)
- server.py: FastAPI on port 8810; /healthz, /admin/tokens/*, /projects/*, /jobs, /jobs/{id}, /jobs/{id}/log, /jobs/{id}/findings
- runner.py: bounded asyncio pool, per-job timeout with process-group SIGTERM→SIGKILL escalation, orphaned-job recovery on boot
- workspace.py: bare-clone + worktree materialization, gc
- config.py: env-driven
- 62 tests across db / auth / projects / jobs / runner / e2e — all green
Cross-token project access returns 404 (not 403) — existence-leak guard.
Bearer tokens hashed at rest; admin token bootstrapped on first boot.
Recipe subprocess uses start_new_session=True so killpg targets the
whole process tree on timeout — child processes can't escape SIGKILL.
Pump task guarded with wait_for(2s) + cancel fallback against any
orphan that survives the group kill.
Wave 2 (parsers + findings extraction + MCP + email digest) pending.
Spec: memory/spec-crafting-table.md
59 lines
1.9 KiB
Python
59 lines
1.9 KiB
Python
"""Env-driven configuration.
|
|
|
|
All settings flow through environment variables so the same image runs in
|
|
prod (compose.yml env_file) and tests (monkeypatched envs). No config files.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
|
|
# Default LAN allowlist mirrors the rules baked into the network: anything
|
|
# inside RFC1918 plus loopback. Override with CRAFTING_LAN_CIDRS if a deploy
|
|
# wants stricter scoping.
|
|
DEFAULT_LAN_CIDRS = (
|
|
"10.0.0.0/8",
|
|
"172.16.0.0/12",
|
|
"192.168.0.0/16",
|
|
"127.0.0.0/8",
|
|
"::1/128",
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Config:
|
|
db_path: Path
|
|
workspace_root: Path
|
|
log_dir: Path
|
|
admin_bearer_path: Path
|
|
max_concurrent_jobs: int
|
|
api_port: int
|
|
api_bind: str
|
|
default_job_timeout_secs: int
|
|
lan_cidrs: tuple[str, ...]
|
|
workspace_gc_interval_secs: int
|
|
workspace_gc_age_secs: int
|
|
|
|
|
|
def load() -> Config:
|
|
cidrs_raw = os.environ.get("CRAFTING_LAN_CIDRS", "").strip()
|
|
if cidrs_raw:
|
|
cidrs = tuple(c.strip() for c in cidrs_raw.split(",") if c.strip())
|
|
else:
|
|
cidrs = DEFAULT_LAN_CIDRS
|
|
|
|
return Config(
|
|
db_path=Path(os.environ.get("CRAFTING_DB", "/data/crafting.db")),
|
|
workspace_root=Path(os.environ.get("CRAFTING_WORKSPACE", "/workspace")),
|
|
log_dir=Path(os.environ.get("CRAFTING_LOG_DIR", "/data/jobs")),
|
|
admin_bearer_path=Path(os.environ.get("CRAFTING_ADMIN_BEARER", "/data/admin-bearer.txt")),
|
|
max_concurrent_jobs=int(os.environ.get("CRAFTING_MAX_CONCURRENT", "4")),
|
|
api_port=int(os.environ.get("CRAFTING_PORT", "8810")),
|
|
api_bind=os.environ.get("CRAFTING_BIND", "0.0.0.0"),
|
|
default_job_timeout_secs=int(os.environ.get("CRAFTING_DEFAULT_JOB_TIMEOUT", "1800")),
|
|
lan_cidrs=cidrs,
|
|
workspace_gc_interval_secs=int(os.environ.get("CRAFTING_GC_INTERVAL", "3600")),
|
|
workspace_gc_age_secs=int(os.environ.get("CRAFTING_GC_AGE", "86400")),
|
|
)
|