Skip to content

Latest commit

 

History

History
136 lines (110 loc) · 5.74 KB

File metadata and controls

136 lines (110 loc) · 5.74 KB

Writing a gatekit module

Escape valve first: if a module you write ends up misbehaving (spamming overdue items, wedging the gate), gatekit off stops gating entirely, unconditionally, with no rate limit -- see the README's Escape valves section. That always works regardless of what any module does, so don't panic-delete anything; just run it.

The contract, in full

A module is anything that periodically writes one JSON file to sources/<module>.json. That's the entire interface. gatekit never calls, spawns, imports, or otherwise knows about a module -- it only reads that directory. A module can be a cron job, a long-running daemon, a one-off script, written in any language, living in any repo, running on any schedule you like.

The file must match schema/source.schema.json. See schema/example-source.json for a minimal worked example.

The one MUST: atomic writes

Write to a temp file in the same directory as your target, then rename it into place. Never write your target file in place with a truncating write -- gatekit (or a concurrent read from your own module) can observe a half-written file mid-write, and a truncate-then-write window means it can observe an empty one.

func writeAtomic(path string, v any) error {
    b, _ := json.MarshalIndent(v, "", "  ")
    f, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
    if err != nil {
        return err
    }
    tmp := f.Name()
    defer os.Remove(tmp) // no-op once the rename below succeeds
    f.Write(b)
    f.Close()
    return os.Rename(tmp, path) // atomic on the same filesystem
}

cmd/gatekit-stalefiles/main.go's own writeAtomic is the reference implementation; copy it if you're writing Go, or match the same temp-file-then-rename shape in whatever language you're using.

The other MUST: carry first_seen_at forward

first_seen_at is the timestamp an item's SLA countdown is measured from. It must be stamped once, the first time your module sees a given item id, and then carried forward unchanged on every later run for as long as that id keeps appearing. If your module re-stamps it to "now" on every run, the SLA never elapses and the item can never become due -- silently defeating the whole point of an SLA.

The concrete pattern (from gatekit-stalefiles): read back your own previous output file at the start of each run, build a map of id -> first_seen_at from it, and consult that map before generating this run's items:

previous := map[string]time.Time{}
if prev, err := readPrevious(outPath); err == nil {
    for _, it := range prev.Items {
        previous[it.ID] = it.FirstSeenAt
    }
}

firstSeen, seenBefore := previous[id]
if !seenBefore {
    firstSeen = now
}

An id that's no longer relevant should simply be omitted from the next run's items -- absence is the signal that it's resolved. There is no separate "resolved" or "cleared" flag to set.

sla_seconds

The grace period, in seconds, after first_seen_at before this item is even eligible to block. 0 means eligible immediately. Most modules want something nonzero -- gatekit's own tiering will hold the item in pending (visible, never blocking, with a "becomes overdue in Xm" countdown) until the SLA elapses.

ok and on_degraded

ok: false tells gatekit your own check didn't run cleanly this time (couldn't reach an API, couldn't read a directory, whatever "couldn't tell" means for your module). Combined with file-mtime staleness (which gatekit computes itself and you cannot misreport), this is the health signal.

on_degraded decides what your degraded state does to your items:

  • "open" (the default -- use this unless you have a specific reason not to): due items go to held instead of overdue while you're unhealthy. This is fail-open: a broken module goes quiet, not silent, and never blocks anyone based on data it isn't confident in.
  • "closed": a due item still blocks exactly as if you were healthy, and gatekit always attaches a warning explaining that your module was degraded when it did. Reach for this only when "I can't tell" is itself the problem you're trying to surface -- most checks are not this.

There is no other way to make a degraded module block silently, on purpose. If you want closed, the warning ships with it, always.

Optional: responding to gatekit reload

gatekit reload (see the README's Reload section) asks every module to re-poll now instead of waiting out its own schedule. gatekit cannot actually make your module do anything -- it only writes a marker file and reports, as a warning, whenever your source file's mtime predates the most recent reload request. Nothing breaks if you never look at this; the worst case is your module catches up on its own next scheduled run, same as always.

If you'd rather respond immediately, watch the mtime of $GATEKIT_STATE_DIR/reload-requested (default ~/.cache/gatekit/state/reload-requested) alongside your own normal schedule, and trigger an out-of-cycle run when it's newer than the mtime you last acted on. Compare mtimes, not the file's content -- the content is a human-readable timestamp for anyone debugging with cat, but mtime is the one fact any language can check without decoding anything. If you poll an external API to do this, debounce on your own side (a naive per-tick check is fine; blindly re-polling on every single reload request in a tight loop is not) -- gatekit places no rate limit on reload itself, since it can never let an overdue item through, only ask you to hurry.

What NOT to build into a module

gatekit itself has zero domain knowledge -- if you find yourself writing integration-specific logic (ticket systems, chat platforms, workflow state), that belongs entirely in your module. gatekit will never grow built-in support for any of it; that's deliberate.