Skip to content

How it Works

Yash Aryan edited this page Oct 9, 2026 · 3 revisions

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.

Agent enroll, sync, and run

Full gallery: Diagrams · Why agent-local scheduling: ADR-001 — Agent-local scheduler (not central)

1. Sign in

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.

2. Add a server

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.

3. Enroll the agent

On the target machine you run the printed command (or agent enroll --token=...). The agent:

  1. Generates an ed25519 keypair and a CSR.
  2. POSTs them to POST /api/v1/agents/enroll with the token (this is the only REST call an agent ever makes).
  3. Receives a signed client certificate and the control-plane CA.
  4. Persists identity under DATA_DIR (/var/lib/croncompose by default).
  5. 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.

4. Connect and sync

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.

5. Write a job

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.

6. Local scheduling and execution

The agent evaluates cron on the machine, in the job’s timezone.

When a job fires (or a RunNow arrives):

  1. Generate a run ULID and send RunStarted.
  2. Apply concurrency: skip (default), allow, or queue.
  3. 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_user if set.
  4. Run under timeout_seconds. Optional systemd-run CPU/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.
  5. Stream stdout/stderr as LogChunks.
  6. 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

7. Watch it run

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.

8. What else rides the same stream

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.

Day in the life (happy path)

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.

Clone this wiki locally