Repository navigation
How it Works
CronCompose has one operator-facing loop: define a server → enroll an agent → write a job → it runs on the box → you watch logs. Everything else (RBAC, secrets, connectors, terminal) rides the same control plane and the same outbound agent stream.

Full gallery: Diagrams · Why agent-local scheduling: ADR-001 — Agent-local scheduler (not central)
The control plane upserts a seed admin from SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD on boot (role owner). You sign in with email and password; the browser stores an HttpOnly session cookie (cc_session, HMAC-SHA256, 7-day TTL). Optional OIDC SSO can sit alongside password login. API callers use a bearer token instead of a cookie.
Roles, lowest to highest: viewer → operator → admin → owner. See Security.
Creating a server in the UI (or cc server add) mints a one-time enrollment token (short TTL, stored only as a hash) and prints an install command. The server row starts as pending until an agent enrolls.
On the target machine you run the printed command (or agent enroll --token=...). The agent:
- Generates an ed25519 keypair and a CSR.
- POSTs them to
POST /api/v1/agents/enrollwith the token (this is the only REST call an agent ever makes). - Receives a signed client certificate and the control-plane CA.
- Persists identity under
DATA_DIR(/var/lib/croncomposeby default). - From then on authenticates with mTLS. The token is never used again.
The server row flips to online. If the cert is compromised, revoke it in the UI; the agent must re-enroll with a fresh token.
Agents only dial out. The target needs no inbound CronCompose ports.
agent run (or the systemd unit) opens a long-lived gRPC bidi stream over mTLS. On connect it sends Hello (version, OS, arch, last sync cursor). The control plane replies with SyncJobs: the full set or a delta of job definitions this agent should hold, with secrets already resolved (the stream is encrypted).
The agent writes those definitions into a local bbolt store and acks with ConfigAck. Editing a job in the UI pushes a delta immediately if the agent is connected; offline agents pick it up on next connect.
In the UI you pick a target (one server, or a label selector such as role=edge), an interpreter (bash / sh / python3 / node), a 5-field cron expression, an IANA timezone, and the script. Optional: timeout, concurrency policy, catch-up policy, working directory, run_as_user, CPU/memory limits, secret refs.
Saving creates job + immutable job_version 1. Later edits to the script insert a new version and bump current_version_id; every run records which version it executed, so history stays accurate.
Built-in job templates (seeded by migration 0008) can start the wizard; you can also save an existing job as a template.
The agent evaluates cron on the machine, in the job’s timezone.
When a job fires (or a RunNow arrives):
- Generate a run ULID and send
RunStarted. - Apply concurrency:
skip(default),allow, orqueue. - Write the script to a temp file, set env (plain env + decrypted secrets as env vars — never interpolated into the script file), drop to
run_as_userif set. - Run under
timeout_seconds. Optionalsystemd-runCPU/memory/process/IO limits; if systemd is not actually running, the agent degrades to unlimited with a note in the log rather than failing the job. - Stream stdout/stderr as
LogChunks. - On exit, send
RunFinished. Timeout kills the process group (timed_out).
While disconnected, the agent keeps firing jobs and writes events to a local outbox. On reconnect it replays them in order. The control plane upserts runs by id, so replay is idempotent.
Catch-up after a long outage (evaluated on agent startup against last fire time):
| Policy | Behaviour |
|---|---|
once (default) |
Run one catch-up no matter how many windows were missed |
all |
Run each missed occurrence, capped at 20 |
skip |
Do nothing |
The run page opens an SSE stream at /api/v1/runs/:id/logs/stream. The control plane first dumps already-persisted lines, then live log events, then a done event with the final status. No polling.
Logs in Postgres are capped per run (RUN_LOG_MAX_BYTES, default 5 MiB). The job itself is not truncated — only stored bytes. Live SSE subscribers still see everything. Optional retention prunes old logs/runs/audit/operations on a schedule (off until you set a window).
Failed runs can notify webhook, Slack, or email targets, scoped by server labels and outcome.
| Feature | What happens |
|---|---|
| Connectors | Agent discovers nginx / systemd / Docker / pm2 (more kinds designed, not all implemented). Portal can read status, edit config with backup+validate, and drive start/stop/reload. |
| Web terminal | Admin/owner opens a PTY in the browser. Keystrokes go control plane → gRPC → agent PTY. Bytes are ephemeral — not stored, not replayed. |
| Agent self-update | Off unless version + HTTPS URL + pinned SHA-256 are all set. Agent verifies the digest, swaps the binary atomically, exits so systemd/pm2 restarts it. |
Operator Control plane Agent
| | |
| add server | |
| copy install command | |
|----------------------------->| mint one-time token |
| | |
| run install on the Pi -------------------------------->|
| |<---- enroll (REST) -----|
| |---- client cert ------->|
| |<==== mTLS stream =======|
| |---- SyncJobs ---------->|
| write job + cron | |
|----------------------------->|---- SyncJobs (delta) -->|
| | | local cron fires
| |<---- RunStarted --------|
| open run page (SSE) |<---- LogChunk ----------|
|<---- live logs --------------|<---- RunFinished -------|
That loop is the product. Architecture detail: Architecture. Wire protocol: Agent Protocol.