CARLOS rastrillo docs

๐Ÿค– carlos

amadan.net/rastrillo/rastrillo/carlos

Work that has to happen at a particular time, on a platform where your process is usually asleep. The platform keeps the clock and POSTs to your app when something is due. This package is your side of that: one call to check the POST really came from the platform, and one to ask for a POST later on.

Recurring work is declared from outside the app, with the platform CLI:

carlos schedule set -name sync -every 6h -path /jobs/sync

Your app's whole part is a handler on /jobs/sync.

It also gives your app the key the platform made for it. See InstanceKey.

Tick

func Tick(r *http.Request) bool

A tick arrives on a public route, so a tick nobody authenticated is an internet request to that path. Tick is the constant-time comparison that tells them apart: the platform presents your instance's $CARLOS_ADMIN_TOKEN as a bearer, and nothing else about the request proves anything.

func handleSync(w http.ResponseWriter, r *http.Request) {
	if !carlos.Tick(r) {
		http.Error(w, "forbidden", http.StatusForbidden)
		return
	}
	if err := syncer.RunOnce(r.Context()); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

Do the work inside the request, as above. The platform holds the instance awake for as long as the request is open and starts the idle clock the moment you return, so returning 202 and finishing in a goroutine gets that goroutine hibernated mid-job. Your status is the whole reply: 2xx means done, 5xx asks for a retry with backoff, 4xx means don't bother.

With no token in the environment Tick is always false. An app the platform never handed the secret to cannot tell a tick from a stranger, and the X-Carlos-Schedule headers are not evidence โ€” anyone can set those.

Tick looks at the bearer and nothing else. That is what lets the same handler serve a "Sync now" button behind your own auth. The scheme is matched case-insensitively, so bearer from a runbook curl works too; the token itself is compared in constant time.

TickOccurrence

func TickOccurrence(r *http.Request) (string, bool)

Delivery is at-least-once, and you will see the same occurrence twice: a handler that outruns the platform's timeout, a box that reboots mid-tick, a deploy that cuts the instance out from under a long job. TickOccurrence returns the key that stays the same across all of those. It is the instant the delivery is for, not the moment it arrived.

if !carlos.Tick(r) {
	http.Error(w, "forbidden", http.StatusForbidden)
	return
}
if occ, ok := carlos.TickOccurrence(r); ok && alreadyDone(occ) {
	w.WriteHeader(http.StatusNoContent)
	return
}

Record it when the work succeeds and skip it when it comes round again. Never key on the wall clock: a retry twenty minutes later is the same occurrence, and treating it as a new one is how a job runs twice.

Keep Tick as the guard, the way the snippet does. TickOccurrence refuses everything Tick refuses, but it also refuses a request with no occurrence header โ€” and that is exactly what your own "Sync now" call looks like. Guard on TickOccurrence alone and the manual path Tick was written to allow gets a 403 over a header nobody has heard of. A missing occurrence means "no dedupe key, run it".

Treat the key as opaque. It is unique within one schedule but not across schedules, so if a single handler serves several, key on the X-Carlos-Schedule name header alongside it.

ScheduleAt

func ScheduleAt(ctx context.Context, name string, at time.Time, path string) error

For work with no rhythm: one reminder, one expiry, one thing at 8am on the first. Ask for it and the platform wakes your instance at at and POSTs to path, which is the same tick your handler already guards.

err := carlos.ScheduleAt(ctx, "remind-"+id, when, "/jobs/remind")

name is your handle on the timer and matches ^[a-z0-9][a-z0-9-]{0,31}$. Registering the same name again replaces the timer rather than adding one, so re-asserting everything you are expecting at boot is safe. The control socket is bound before your process starts, so that works from the first line of main. You mostly won't need it: timers live in the box's registry, not in your process, and a restart loses none of them.

at has to be set, and no more than MaxAhead (400 days) out. Both bounds are checked here before anything is sent. The zero time is the one worth knowing about: the platform would accept it, because an at in the past is legal and fires on the next sweep, so a field nobody filled in would reach someone as a reminder at boot rather than as an error you can see. A deliberately past at is still fine โ€” pass a real one.

The call goes to the agent over a unix socket. That is quick, but it is still I/O, so pass a context you are willing to wait on. One with no deadline of its own gets a ten-second one.

ScheduleCancel

func ScheduleCancel(ctx context.Context, name string) error

Drops a timer ScheduleAt registered. Cancelling something that already fired, or never existed, succeeds, so a cleanup path doesn't have to know which.

InstanceKey

func InstanceKey(own string) (string, error)

Returns the key your app seals with. Pass it to auth.Config.InstanceKey, pow.Config.InstanceKey and anything else that takes one.

On CARLOS, the platform makes this key before your app first starts and backs it up before the app runs. Use it instead of making your own. A key your app makes at startup can be lost with the box before a backup carries it. The data comes back on another box, but nothing sealed with that key can be read.

Pass your app's own key as own, or "" if it has none. Read it from your setting or key file, but don't make one yet.

own, err := loadKeyWithoutMinting(dataDir)
if err != nil {
	return err
}
key, err := carlos.InstanceKey(own)
if errors.Is(err, carlos.ErrNotOnCarlos) {
	key, err = mintKey(dataDir) // off CARLOS: what you did before
}
if err != nil {
	return err // ErrNoInstanceKey: don't start
}

It returns the first of these that applies:

  1. own, if it isn't empty. Keep a key your app already has: changing it makes everything sealed with the old one unreadable.
  2. $CARLOS_INSTANCE_KEY, exactly as set.
  3. On CARLOS, ErrNoInstanceKey. Don't start.
  4. Off CARLOS, ErrNotOnCarlos. Load or make a key the way you did before.

Host

const HostHeader = "Rastrillo-Host"

func Host() string

Host returns $CARLOS_HOST, the platform hostname this instance answers on. Off CARLOS it returns "".

HostHeader is the header that names the instance on GET /api/version when the app sets rastrillo.Options.NameInstance. provision reads it to tell which instance answered.

The errors

var (
	ErrNotOnCarlos      error
	ErrNoInstanceKey    error
	ErrUnauthorized     error
	ErrDeclaredSchedule error
	ErrTooManyTimers    error
)

All five are sentinels; compare with errors.Is.

ErrNotOnCarlos means there is no control socket in the environment, which is what your laptop and your tests look like. Boot code that registers timers should treat it as "skip", not as a failure. From InstanceKey, it means load or make your own key.

ErrNoInstanceKey means your app is on CARLOS, has no key of its own, and the platform gave it none. Don't start. A key made now could be lost before a backup carries it.

ErrUnauthorized means no $CARLOS_ADMIN_TOKEN, or one the agent would not take. It is what an instance sees if it was already running when its box first learned these verbs, because the token is minted into the environment at spawn; restarting the instance clears it.

ErrDeclaredSchedule means the name belongs to a recurring schedule somebody declared with carlos schedule set. Both kinds share a namespace, and an app cannot quietly overwrite or cancel work its operator set up. Pick another name.

ErrTooManyTimers means the box is holding its limit of 1000 pending one-shot timers for this app. Cancel finished ones, or if you are registering the same shape of work over and over, that is a recurring schedule wearing a disguise.

StatusError

type StatusError struct {
	Status int
	Name   string
	Body   string
}

func (e *StatusError) Error() string

Anything the agent refused that has no sentinel comes back as this โ€” a rejected path, a malformed body. Pull it out with errors.As when you need to decide what to do next, because the two directions are opposite: a 5xx is the agent's registry having a bad moment and is worth retrying, a 4xx is a permanent complaint about this exact request and retrying it is a loop. Body is the agent's own message, which is usually the only place the real reason is written down.

Running

func Running() bool

Running reports whether the app was started by CARLOS. Rastrillo uses it for defaults that are only right on the platform, such as trusting the edge's X-Forwarded-For.

Read this page as markdown โ€” exact, unstyled, and cheap for an agent to fetch.