Report API and CLI

Jobs in Kubernetes are found by the agent. Everything else (a Windows scheduled task, a macOS launchd job, a GitHub Actions schedule, a Lambda, a probe on a NAS) pushes its result to jobwatch. The first report for a name creates the check, so there is no setup step per job, and the job owns its own schedule.

Contract: proto/phase5.md. Server and web details: docs/architecture.md section 11.

1. Create a key

Settings → API keys → New key (Admins and Owners; a cluster-scoped Admin can only create keys inside their own clusters).

FieldNotes
RoleReporter can only send reports (POST /v1/report, GET /v1/report/config) and gets 403 everywhere else: use it for scripts and CI. Viewer, Operator and Admin act like a member with that role, plus report. A key never has a higher role than its creator, and never Owner.
ClustersFor jobs outside Kubernetes a “cluster” is an environment label (ci, billing-prod, casino-ops). The key may only report to, and see, these. Roles, scoping and routing work like for real clusters.
ExpiresOptional. Past it the key gets 401.

The secret (jwk_…) is shown once; jobwatch stores only a hash. Rotate a key to get a new secret: the old one keeps working for the overlap you choose (default 24 h) so you can switch jobs without downtime. Revoking is final and takes effect within 10 seconds. Keys cannot create other keys.

Put the key in the environment of the job, never in the command line:

export JOBWATCH_URL=https://jobwatch.example.com
export JOBWATCH_API_KEY=jwk_...

2. The CLI

Download jobwatch-report-<os>-<arch>[.exe] from the agent release (linux, darwin, windows × amd64, arm64; verify it as in verify.md). One static binary, no runtime.

jobwatch-report run  --check NAME [--cluster C] [--schedule CRON --tz TZ | --every DUR] [--grace DUR]
                     [--max-duration DUR] [--label k=v]... [--no-logs] [--spool DIR] -- command [args...]
jobwatch-report send --check NAME --status start|success|fail [--summary S] [--exit-code N] [--run-id ID]
                     [--duration 2m3s] [--reason MSG] [--log-file PATH|-] ...
jobwatch-report config                      # log mode, org key fingerprint, allowed clusters

run sends start, runs the command with stdin/stdout/stderr passed through, keeps the last 20 lines, masks secrets (proto/fixtures/masking.json, including jwk_/jwa_ strings), seals them in a sealed org, sends success or fail with the exit code and duration, and exits with the command’s exit code. Reporting never fails the job: 10 s timeout, 3 attempts with back-off, then a warning on stderr (a missing key only costs the reports). With --spool DIR, events that could not be delivered are kept (mode 0600) and sent before the next run.

Declarative monitoring (--schedule/--every, --grace, --max-duration, --label) goes with the reports: what you pass updates the check, what you leave out is kept. Without a schedule the check is unscheduled: failures alert, but a missing run cannot. A run longer than --max-duration opens a ping_slow incident.

Logs follow the org’s mode: plaintext orgs get the masked tail; sealed orgs get it end-to-end encrypted with the org key (the server stores ciphertext and never decrypts); metadata orgs get none. Set JOBWATCH_ORG_KEY_FINGERPRINT (the fingerprint from Settings → Encryption) to pin the key: on a mismatch, an unknown mode or any error, no log leaves the machine. --no-logs always wins.

3. Examples

Linux cron, systemd, any shell

0 3 * * *  JOBWATCH_URL=... JOBWATCH_API_KEY=... jobwatch-report run --check billing/export --cluster billing-prod --schedule "0 3 * * *" --tz Europe/Bucharest --grace 30m -- /opt/billing/export.sh

Plain curl

curl -sS -X POST "$JOBWATCH_URL/v1/report" \
  -H "Authorization: Bearer $JOBWATCH_API_KEY" -H 'Content-Type: application/json' \
  -d '{"check":"billing/export","cluster":"billing-prod","status":"fail","exitCode":3,
       "reason":{"code":"exit_code","message":"export.sh exited 3"},"summary":"upload failed",
       "logTail":["upload failed: 403 Forbidden"],"periodSeconds":86400,"graceSeconds":1800}'
# → 202 {"checkId":"…","jobKey":"billing-prod/checks/Check/…","created":true}

Plaintext orgs only: mask secrets in logTail yourself. Sealed orgs: use the CLI (or implement jwseal1).

PowerShell (no binary)

$body = @{ check = 'billing/export'; cluster = 'billing-prod'; status = 'success'; durationMs = 41000;
           periodSeconds = 86400; graceSeconds = 1800 } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri "$env:JOBWATCH_URL/v1/report" -Body $body -ContentType 'application/json' `
  -Headers @{ Authorization = "Bearer $env:JOBWATCH_API_KEY" } -TimeoutSec 10

Windows Task Scheduler

Action: C:\tools\jobwatch-report.exe, arguments run --check billing/export --cluster billing-prod --every 1d --grace 30m -- C:\jobs\export.cmd. Put JOBWATCH_URL and JOBWATCH_API_KEY in the task’s account environment (System Properties → Environment Variables, or setx for that user), not in the arguments.

macOS launchd

<key>ProgramArguments</key><array>
  <string>/usr/local/bin/jobwatch-report</string><string>run</string><string>--check</string><string>mac/backup</string>
  <string>--cluster</string><string>laptops</string><string>--every</string><string>1d</string><string>--</string>
  <string>/Users/me/bin/backup.sh</string></array>
<key>EnvironmentVariables</key><dict>
  <key>JOBWATCH_URL</key><string>https://jobwatch.example.com</string>
  <key>JOBWATCH_API_KEY</key><string>jwk_...</string></dict>

(Keep the plist readable by you only.)

GitHub Actions

on:
  schedule: [{ cron: "0 3 * * *" }]
jobs:
  nightly:
    runs-on: ubuntu-latest
    env:
      JOBWATCH_URL: ${{ vars.JOBWATCH_URL }}
      JOBWATCH_API_KEY: ${{ secrets.JOBWATCH_API_KEY }}   # a Reporter key limited to cluster "ci"
    steps:
      - uses: actions/checkout@v4
      - run: |
          curl -fsSLO https://github.com/<owner>/jobwatch/releases/download/agent-v<version>/jobwatch-report-linux-amd64
          chmod +x jobwatch-report-linux-amd64
          ./jobwatch-report-linux-amd64 run --check github/nightly --cluster ci --schedule "0 3 * * *" --grace 30m -- ./scripts/nightly.sh

AWS Lambda (any language, plain HTTPS)

import json, os, urllib.request

def report(status, **extra):
    body = json.dumps({"check": "lambda/nightly", "cluster": "aws", "status": status, **extra}).encode()
    req = urllib.request.Request(os.environ["JOBWATCH_URL"] + "/v1/report", body, {
        "Authorization": "Bearer " + os.environ["JOBWATCH_API_KEY"], "Content-Type": "application/json"})
    try:
        urllib.request.urlopen(req, timeout=5)
    except Exception as e:           # never fail the function because of reporting
        print("jobwatch report failed:", type(e).__name__)

def handler(event, ctx):
    report("start", runId=ctx.aws_request_id)
    try:
        ...
        report("success", runId=ctx.aws_request_id, periodSeconds=86400)
    except Exception as e:
        report("fail", runId=ctx.aws_request_id, reason={"code": type(e).__name__, "message": str(e)[:200]})
        raise

4. The API in short

POST /v1/report (one event, 202 {checkId, jobKey, created}), POST /v1/report/batch ({events:[…≤100]} → 207, one result per event), GET /v1/report/config. Bodies are JSON: ≤ 64 KiB (batch ≤ 1 MiB). Auth: Authorization: Bearer jwk_… over TLS. Identity of a check is (org, cluster, check).

Event fieldNotes
check (required)[A-Za-z0-9._/-]{1,200}
status (required)start, success or fail. start opens a run, success/fail finish it (paired by runId, else the latest open start), or record a one-shot run (a probe).
clusterdefault: the key’s only cluster, else custom
eventId≤ 64 chars; the same id within 24 h is accepted again without side effects (safe retries)
runId, at, durationMs, exitCode, summary (≤ 200), reason {code, message} (fail only)at defaults to receive time (≤ 300 s ahead, ≤ 24 h back)
logTail (≤ 20 × 1000) / logEnvelopeplaintext orgs / sealed orgs (needs runId of [A-Za-z0-9._-], bound to {cluster}/report/{check}); the other form is dropped and counted
schedule + timeZone or periodSeconds, graceSeconds, maxDurationSecondsdeclarative; present = update, absent = keep
labels≤ 16, keys [a-z0-9_.-]{1,63}, values ≤ 200

Errors are application/problem+json: 400 with a field path, 401 unknown/revoked/expired key, 403 role or cluster outside the key, 409 check-limit (default 1000 report checks per org), 413, 429 with Retry-After (default 20 requests/s, burst 200, per key). Incidents: ping_failed (a fail), ping_missed (nothing at due time + grace), ping_slow (longer than maxDurationSeconds); the next good run resolves them, and a failure takes precedence over a miss over slow.

Reports are authenticated but not signed (unlike agent batches): see SECURITY.md.