#!/usr/bin/env python3 """ Register the Phase C production Scheduler tasks (deploy-checklist helper). Defines the four task payloads from docs/scheduler-tasks.md: 1. library_integrity_check - nightly 04:30 2. library_quality_report - Sunday 03:00 3. library_paperless_orphan_cleanup - daily 05:00 4. test_example_task - DISABLED (update, not create) SAFETY MODEL ============ - DRY-RUN BY DEFAULT: without --execute the script only prints the exact payloads it would send. Nothing is contacted except (optionally) the Scheduler health endpoint. - --execute performs the registration: create task if absent, update it if present, and disable test_example_task. - The Scheduler API location comes from the environment (SCHEDULER_URL); there is no hardcoded production default. - The Scheduler's task-management endpoints are themselves guarded by Bearer auth (verify_api_key). --execute therefore requires SCHEDULER_API_KEY in the environment; the registrar sends it as ``Authorization: Bearer `` on its own HTTP calls. It is read from the environment only and never stored anywhere. - NO SECRET IS EVER STORED: the library-desk API key is referenced as the literal placeholder ``${LIBRARY_API_KEY}`` inside the task's ``auth.token`` field. The Scheduler's rest_api_executor substitutes ``${ENV_VAR}`` placeholders from ITS OWN environment at execution time (it substitutes url/payload/auth — NOT plain headers), so the raw token never lands in the scheduled_tasks.config JSONB column. The Scheduler container must therefore have LIBRARY_API_KEY in its environment. Usage: # Preview (default) SCHEDULER_URL=http://scheduler-host:8090 \ python scripts/register_scheduler_tasks.py # Register for real (deploy checklist step) SCHEDULER_URL=http://scheduler-host:8090 \ SCHEDULER_API_KEY= \ python scripts/register_scheduler_tasks.py --execute """ import argparse import json import os import sys import httpx API_KEY_PLACEHOLDER = "${LIBRARY_API_KEY}" PRODUCTION_TENANT = "jpmschweitzer" LIBRARY_BASE_URL = "http://library-desk:8089" #: Tasks to create-or-update (see docs/scheduler-tasks.md). TASKS = [ { "task_name": "library_integrity_check", "service": "library-desk", "executor": "rest_api_executor", "priority": 60, "description": ( "Nightly read-only integrity check for the library " "(vectors/graph/wiki/collections)" ), "enabled": True, "max_retries": 2, "timeout_seconds": 900, "minute": 30, "hour": 4, "day_of_month": -1, "month": -1, "day_of_week": -1, "config": { "method": "POST", "url": f"{LIBRARY_BASE_URL}/maintenance/integrity-check", "headers": {"Content-Type": "application/json"}, # rest_api_executor sends config["payload"] as the JSON body and # substitutes ${ENV_VAR} in auth.token from the Scheduler's env. "payload": {"user": PRODUCTION_TENANT}, "auth": {"type": "bearer", "token": API_KEY_PLACEHOLDER}, }, }, { "task_name": "library_quality_report", "service": "library-desk", "executor": "rest_api_executor", "priority": 60, "description": ( "Weekly library quality report (dedup, stale pages, missing " "metadata, integrity) written to the wiki" ), "enabled": True, "max_retries": 2, "timeout_seconds": 1800, "minute": 0, "hour": 3, "day_of_month": -1, "month": -1, "day_of_week": 6, # Sunday (0 = Monday) "config": { "method": "POST", "url": f"{LIBRARY_BASE_URL}/maintenance/quality-report", "headers": {"Content-Type": "application/json"}, "payload": { "user": PRODUCTION_TENANT, "stale_days": 30, "dedup_threshold": 0.9, "write_page": True, }, "auth": {"type": "bearer", "token": API_KEY_PLACEHOLDER}, }, }, { "task_name": "library_paperless_orphan_cleanup", "service": "library-desk", "executor": "rest_api_executor", "priority": 60, "description": ( "Daily cleanup of vectors/graph nodes for documents deleted " "from Paperless-ngx" ), "enabled": True, "max_retries": 2, "timeout_seconds": 900, "minute": 0, "hour": 5, "day_of_month": -1, "month": -1, "day_of_week": -1, "config": { "method": "POST", "url": ( f"{LIBRARY_BASE_URL}/maintenance/cleanup/paperless" f"?user={PRODUCTION_TENANT}&dry_run=false" ), "payload": {}, "auth": {"type": "bearer", "token": API_KEY_PLACEHOLDER}, }, }, ] #: Existing tasks to update in place. TASK_UPDATES = [ {"task_name": "test_example_task", "updates": {"enabled": False}}, ] def dry_run(scheduler_url: str) -> None: print("=" * 72) print("DRY RUN - nothing will be sent. Re-run with --execute to register.") print(f"Scheduler API: {scheduler_url or '(SCHEDULER_URL not set)'}") print("=" * 72) for task in TASKS: print(f"\n--- create-or-update: POST {scheduler_url}/tasks " f"(or PUT /tasks/{task['task_name']}) ---") print(json.dumps(task, indent=2)) for update in TASK_UPDATES: print(f"\n--- update: PUT {scheduler_url}/tasks/{update['task_name']} ---") print(json.dumps(update["updates"], indent=2)) print("\nDry run complete: " f"{len(TASKS)} task definition(s), {len(TASK_UPDATES)} update(s).") def execute(scheduler_url: str, scheduler_api_key: str) -> int: failures = 0 # The Scheduler's task-management endpoints require Bearer auth # (verify_api_key: 401 when missing, 403 when wrong). Without this # header the existence probes 401 (misread as "task absent") and # every POST/PUT fails. auth_headers = {"Authorization": f"Bearer {scheduler_api_key}"} with httpx.Client( base_url=scheduler_url, timeout=30.0, headers=auth_headers ) as client: health = client.get("/health") if health.status_code != 200: print(f"ERROR: Scheduler health check failed: {health.status_code}") return 1 for task in TASKS: name = task["task_name"] # Sent verbatim: the ${LIBRARY_API_KEY} placeholder is resolved # by the Scheduler at execution time, never stored as a raw key. payload = task exists = client.get(f"/tasks/{name}").status_code == 200 if exists: resp = client.put(f"/tasks/{name}", json=payload) action = "updated" else: resp = client.post("/tasks", json=payload) action = "created" if resp.status_code == 200: print(f"[ok] {action} {name}") else: failures += 1 print(f"[FAIL] {action} {name}: {resp.status_code} {resp.text[:200]}") for update in TASK_UPDATES: name = update["task_name"] if client.get(f"/tasks/{name}").status_code != 200: print(f"[skip] {name} does not exist - nothing to disable") continue resp = client.put(f"/tasks/{name}", json=update["updates"]) if resp.status_code == 200: print(f"[ok] updated {name}: {update['updates']}") else: failures += 1 print(f"[FAIL] update {name}: {resp.status_code} {resp.text[:200]}") return 1 if failures else 0 def main() -> int: parser = argparse.ArgumentParser( description="Register library-desk production Scheduler tasks " "(dry-run by default)" ) parser.add_argument( "--execute", action="store_true", help="Actually register/update the tasks (default: dry-run print only)", ) parser.add_argument( "--dry-run", action="store_true", help="Explicitly print payloads without sending (the default behavior)", ) args = parser.parse_args() scheduler_url = os.environ.get("SCHEDULER_URL", "").rstrip("/") if not args.execute or args.dry_run: dry_run(scheduler_url) return 0 if not scheduler_url: print("ERROR: SCHEDULER_URL must be set for --execute") return 1 scheduler_api_key = os.environ.get("SCHEDULER_API_KEY", "") if not scheduler_api_key: print("ERROR: SCHEDULER_API_KEY must be set for --execute " "(the Scheduler's task endpoints require Bearer auth)") return 1 return execute(scheduler_url, scheduler_api_key) if __name__ == "__main__": sys.exit(main())