groot-trigger

August 19, 2026 · View on GitHub

Status: Approved (2026-08-12). Behavior contract for v0.1.2.
Repo: github.com/hrodrig/groot-trigger
Design history: docs/superpowers/specs/2026-08-12-groot-trigger-design.md
Not in scope: GROOT CLI behavior; Helm CronJob packaging (groot / groot-selfhosted).


1. Problem

Operators want a “Generate GROOT files” control (browser or HTTP client) that:

  1. Runs an in-cluster groot collect
  2. Optionally uploads the archive with groot (upload.s3 / upload.gcs / upload.sftp; HTTP(S)/WebDAV planned in groot)
  3. Returns quickly without blocking the client for minutes

The product ships the button inside groot-trigger: GET /v1/collect serves a minimal HTML page; POST /v1/collect starts the Job. No separate web app required.

The groot product is a one-shot CLI by design (no HTTP server, no long-lived collector daemon). Putting an API inside groot would break that philosophy.

2. Goals / non-goals

Goals

  • Idle HTTP Deployment that creates a Kubernetes Job running ghcr.io/hrodrig/groot
  • Built-in UI: GET /v1/collect → vanilla HTML/CSS (embedded; no CSS framework) with API key field + button “Generate GROOT files” (English)
  • Fire-and-forget: POST /v1/collect → 202 Accepted + run_id (JSON or HTML result page)
  • API key auth on POST /v1/collect (and on the HTML form); refuse to start if key unset
  • Rate limit on POST (per client IP; optional global) → 429
  • Trusted proxies opt-in (CIDRs); default ignore forwarded headers
  • Single-flight: 409 Conflict if a collect Job is already Pending/Running
  • Reuse operator config patterns from groot-selfhosted (ConfigMap groot.yml, Secret for AWS_*, image pin vX.Y.Z)
  • English-only artifacts; companion to groot, not a fork of the collector

Non-goals (v0.1.x)

  • OIDC / mTLS / per-user identity (Phase 2)
  • Status poll / download proxy / presigned URL API
  • Live watch / event-driven collect (upstream ROADMAP #55 — separate)
  • Embedding collector code or serving HTTP from the groot binary
  • Multi-cluster
  • Replacing Helm CronJob schedules (CronJob remains optional scheduled path)

3. Architecture

┌─────────────┐  GET  /v1/collect     ┌──────────────────────┐
│  Browser /  │ ─────────────────────► │  HTML: API key +     │
│  curl       │ ◄──── page             │  “Generate GROOT…” │
│             │                        │                      │
│             │  POST + API key        │  groot-trigger       │
│             │ ─────────────────────► │  Deployment (idle)   │
│             │ ◄──── 202 / 401 / 409  │                      │
└─────────────┘                        └──────────┬───────────┘
                                                  │ batch/v1 Job create
                                                  ▼
                                       ┌──────────────────────┐
                                       │  Job Pod             │
                                       │  image: groot:vX.Y.Z │
                                       │  collect → upload    │
                                       │  → exit              │
                                       └──────────────────────┘

Model A (locked): no idle groot pod. Only groot-trigger stays up. Each authenticated POST spawns a one-shot Job.

ComponentLives inRole
CLI + imagegrootUnchanged one-shot collect / upload / notify
CronJob / Helmgroot-selfhostedOptional schedule; Job template reference
Triggergroot-triggerGET page + auth + POST → Job; concurrency gate

4. HTTP contract

Authentication (required)

Shared API key from env GROOT_TRIGGER_API_KEY (Kubernetes Secret → env). Process exits on startup if the key is empty (fail closed).

How clients send the key (any one accepted):

ClientMechanism
Browser formField api_key (input type=password) on POST body (application/x-www-form-urlencoded)
curl / automationHeader Authorization: Bearer <key> or X-API-Key: <key>

Rules:

  • Constant-time compare (crypto/subtle)
  • Missing / wrong key → 401 (JSON or HTML); no Job create
  • Never accept the key via query string (leaks in access logs / Referer)
  • /healthz and /readyz stay unauthenticated (probes)
  • GET /v1/collect may stay unauthenticated (page only; no collect). Collect action is always POST + key

API key is a shared secret, not per-user identity. Still prefer ClusterIP / no public Ingress. Key ≠ network isolation.

Rate limit

In-process limiter (no Redis). Complements 409 single-flight (concurrency) with request throttling (auth brute-force / spam).

ScopeDefault (configurable)
Per client IP on POST /v1/collecte.g. 10 req / minute
Optional global cap on POSTe.g. 30 req / minute
  • Exceeded → 429 Too Many Requests (+ Retry-After when practical)
  • /healthz / /readyz not rate-limited
  • GET /v1/collect lightly limited or unlimited (static page); priority = protect POST
  • Implementation: golang.org/x/time/rate or equivalent token bucket; memory keyed by client IP

Client IP / trusted proxies

Default (safe for ClusterIP / port-forward): client IP = RemoteAddr only. Ignore X-Forwarded-For / X-Real-IP.

When behind Ingress / reverse proxy, set trusted proxy CIDRs. Only then peel forwarded headers from a peer in that set.

EnvPurpose
GROOT_TRIGGER_TRUSTED_PROXIESComma-separated CIDRs (e.g. 10.0.0.0/8,192.168.0.0/16). Empty = do not trust forwarded headers
GROOT_TRIGGER_RATE_LIMIT_POSTPOST per-IP limit (e.g. 10/1m); 0 disables
GROOT_TRIGGER_RATE_LIMIT_GLOBALOptional global POST cap; 0 = off

Wrong trusted-proxy config → spoofed IPs → broken rate limits / misleading logs. Document: leave empty unless Ingress is intentional.

GET /v1/collect

Serves a minimal HTML page (English UI strings):

  • Stack: vanilla HTML + CSS only — no Tailwind, Bootstrap, JS framework, or CDN stylesheets. Embed templates/CSS in the Go binary (embed)
  • Title / brand: GROOT trigger
  • Password field: API key
  • One primary control: button label “Generate GROOT files”
  • Form: method=POST, action=/v1/collect, fields api_key (+ optional message, max 48 Unicode characters)
  • Footer (monospace): POST /v1/collect · fire-and-forget · v<version> — version from build ldflags (dev when unset)
  • Visual: operator utility (CSS custom properties, sober palette, monospace for run_id / status). No marketing hero, cards, or stat strips
  • No status poll, no download list

Optional later: if Accept: application/json, return a short JSON description of the endpoint (not required in v0.1.x). Default for browsers = text/html.

POST /v1/collect

Starts a collect Job after successful API key check. Clients: browser form, fetch, or curl.

Request body:

  • Form: api_key (required for browser), optional message (max 48 Unicode characters after trim; empty = omit)
  • Optional JSON (Content-Type: application/json) when using headers for auth:
{
  "message": "optional operator note; groot --message archive suffix"
}

Longer message → 400 {"error":"message_too_long"}. Groot sanitizes the value (lowercase, filesystem-safe) and appends it as the archive basename suffix (<sessionBase>-<cluster>[-<message-suffix>].tar.gz). This service does not change groot naming.

(Do not put the API key in JSON if a header is used; form field api_key or header still required.)

Responses (content negotiation):

Prefer JSON when Accept includes application/json or request used JSON / X-API-Key / Authorization. Otherwise return a simple HTML result page with a link back to GET /v1/collect.

CodeWhenJSON bodyHTML
202Job created{"run_id":"<id>","job":"<job-name>"}“Collect started” + run_id + link back
401Missing / invalid API key{"error":"unauthorized"}“Unauthorized” + link back
409Collect Job with label app.kubernetes.io/name=groot-trigger-collect is Pending or Running{"error":"collect_in_progress","job":"<existing>"}“Collect already in progress” + link back
429Rate limit exceeded{"error":"rate_limited"}“Too many requests” + link back
400Malformed JSON or message longer than 48 characters{"error":"bad_request"} or {"error":"message_too_long"}Short error + link back
500API / RBAC / apiserver failure{"error":"internal","detail":"..."} (no secrets)Short error + link back

No GET /v1/collect/{id} in v0.1.x. Completion signal = notify channels and/or object appearing in the bucket.

GET /healthz

Liveness: 200 if process up (no apiserver check required).

GET /readyz

Readiness: 200 if in-cluster config / Job client can be constructed (lightweight).

5. Job shape

  • Name: groot-collect-<run_id_short> (DNS-1123 safe)
  • Labels:
    • app.kubernetes.io/name=groot-trigger-collect
    • app.kubernetes.io/part-of=groot-trigger
    • groot-trigger/run_id=<run_id>
  • Image: configurable; default ghcr.io/hrodrig/groot:v1.1.1 (GHCR publishes v-prefixed tags only)
  • Args: collect --config /config/groot.yml (+ optional --verbose via GROOT_EXTRA_ARGS) (+ optional --message <text> when POST message is non-empty)
  • ServiceAccount: Job SA with read-only collector ClusterRole (same shape as groot-selfhosted). Standalone installs apply deploy/k8s/job-sa/ (groot). When groot-selfhosted Helm already created that SA, skip job-sa/ and set GROOT_JOB_SA to the Helm SA name (default groot when the release is groot)
  • Volumes: ConfigMap (groot.yml, read-only), PVC or emptyDir for /out (operator choice), emptyDir for /tmp (read-only root filesystem)
  • Security: Job pod/container runAsNonRoot UID/GID 65532 (distroless nonroot), readOnlyRootFilesystem: true, drop ALL capabilities, fsGroup: 65532 so PVC /out is writable
  • envFrom: optional Secret for groot upload (AWS_* for S3, GCS ADC, GROOT_UPLOAD_SFTP_* for SFTP)
  • TTL: ttlSecondsAfterFinished set so completed Jobs are garbage-collected

Single-flight

Trigger lists Jobs with label app.kubernetes.io/name=groot-trigger-collect in Active (not succeeded/failed). If any → 409.

Note (lab spike): CronJob concurrencyPolicy: Forbid does not block kubectl create job --from=…. The 409 gate must live in groot-trigger, not rely on Forbid alone.

6. Configuration

Trigger Deployment env / ConfigMap (examples):

KeyPurpose
GROOT_IMAGEJob container image
GROOT_NAMESPACENamespace for Jobs (default: pod namespace)
GROOT_CONFIGMAPConfigMap name mounting groot.yml
GROOT_CONFIG_KEYKey inside CM (default groot.yml)
GROOT_OUT_PVCOptional PVC claim name for /out
GROOT_JOB_SAServiceAccount name for Job pods
GROOT_EXTRA_ARGSExtra CLI args (e.g. --verbose)
GROOT_ENVFROM_SECRETOptional Secret name for Job envFrom
GROOT_TRIGGER_API_KEYRequired. Shared secret; empty → process exit
GROOT_TRIGGER_TRUSTED_PROXIESOptional CIDR list; empty = ignore X-Forwarded-*
GROOT_TRIGGER_RATE_LIMIT_POSTPer-IP POST limit (default e.g. 10/1m; 0 = off)
GROOT_TRIGGER_RATE_LIMIT_GLOBALOptional global POST limit (0 = off)
GROOT_TRIGGER_LOG_FORMATjson (default) or text
GROOT_TRIGGER_LOG_LEVELinfo (default), debug, warn, error
LISTEN_ADDRDefault :8080

Upload/bucket settings stay in groot.yml (ConfigMap), not in trigger code. Credentials stay in Secrets.

7. RBAC

Trigger SA (API pod):

  • create, get, list, watch on batch/jobs in the target namespace
  • get, list, watch on pods (optional; for debugging / future status)
  • Not cluster-wide collect rights

Job SA (collect pod):

  • Same read-only collector ClusterRole as groot-selfhosted (pods/logs, events, nodes, workloads, metrics, …)
  • Created by deploy/k8s/job-sa/ or reused from a groot-selfhosted Helm release in the same namespace (do not apply job-sa/ on top of Helm)

8. Error handling & observability

  • Failed Job create → 500 + structured log (no AWS keys)
  • Collect/upload failures inside Job → Job Failed; operator sees notify / Job events; API already returned 202
  • Metrics (stretch): groot_trigger_collect_requests_total{result=accepted|conflict|unauthorized|rate_limited|error}

Logging

Model: gghstats (HTTP service), not groot CLI logx.

Piecegghstatsgroot-trigger
Librarylog/slogsame
Level envGGHSTATS_LOG_LEVELGROOT_TRIGGER_LOG_LEVEL (debug/info/warn/error, default info)
FormatJSON/text via handlerGROOT_TRIGGER_LOG_FORMAT = json (default) or text
HTTP accessmsg "http" + method, path, status, ip, dursame (+ run_id / result on collect)
Level by status<400 Info · 4xx Warn · 5xx Error (httpAccessLogLevel)same
Probesskip /healthz in access logskip /healthz + /readyz
Prefixline prefix gghstats optional groot-trigger (grep in shared streams)
Startupone banner line (version, listen, masked secrets)same (mask API key; never print full key)
Trusted IPTrustedProxies + clientIPsame as rate-limit / access log

Never log: API key, Authorization, form api_key, AWS keys, full Secret values.

Startup config summary: image, namespace, rate limits, trusted-proxy on/off — not the key.

9. Security

  • API key required for collect (POST); fail closed if unset
  • Rate limit on POST (per-IP ± global); 429
  • Trusted proxies opt-in via CIDRs; default ignore forwarded headers
  • Service ClusterIP only; no Ingress in default manifests (defense in depth with the key)
  • Document: anyone with the key + network path can start a full-cluster read collect + upload — treat the key like a credential; rotate via Secret
  • Phase 2: OIDC / mTLS / short-lived tokens
  • Distroless / nonroot image for trigger binary
  • No shell in Job image (official groot distroless)
  • Collect Job: readOnlyRootFilesystem; writes only /out (and /tmp emptyDir)

10. Testing

LayerWhat
Unitrun_id; busy check; auth 401; rate limit 429; trusted-proxy IP picking; GET HTML form; POST JSON/HTML
Integrationenvtest or kind: POST without key → 401; with key → Job; burst → 429
LabBrowser / port-forward → form + key → POST; Job+S3 path already validated on a lab cluster

11. Repo layout

groot-trigger/
  docs/SPECIFICATIONS.md          # this contract
  docs/superpowers/specs/…        # design history
  cmd/groot-trigger/
  internal/
  deploy/k8s/always/              # Trigger SA, Role, ConfigMap, Deployment, Service
  deploy/k8s/job-sa/              # Job SA + collector ClusterRole; skip if Helm owns the Job SA
  Dockerfile / Dockerfile.release
  .goreleaser.yaml / Makefile

Stack: Go 1.26.x (align with groot), client-go, stdlib net/http (keep deps small). Packaging mirrors groot (distroless, GoReleaser, v-prefixed image tags).

12. Relationship to siblings

RepoBoundary
grootProduct CLI + ghcr.io/hrodrig/groot
groot-selfhostedHow to schedule/deploy collect (Helm CronJob, docker, examples)
groot-triggerOn-demand HTTP → Job
groot-shareArchive inbox / share UI (out of scope here)

13. Lab spike notes (2026-08-12)

Validated on a lab cluster before this design:

  1. Manual Job from CronJob template → collect OK (~15–25s lab)
  2. Overlapping manual Jobs both run despite Forbid
  3. Contabo S3 upload OK with Secret AWS_* + upload.s3 endpoint without bucket path suffix
  4. Upload success log line requires --verbose (logger.OK is verbose-gated)
  5. Image tag must be v1.1.1, not 1.1.1
  6. Helm chart groot-selfhosted 0.1.13 ships extraEnvFrom / extraArgs and v-tag normalization

14. Implementation notes

Application code implements this SPEC. Image and binaries ship via GoReleaser (v-prefixed tags).


Decision log

DecisionChoice
WhereNew repo groot-trigger, not inside groot
Runtime modelA — Job on demand; trigger idle
UIGET /v1/collect = vanilla HTML (API key + “Generate GROOT files”); POST starts Job
FrontendVanilla embed (HTML/CSS); no Tailwind/Bootstrap/CDN
AuthShared API key (GROOT_TRIGGER_API_KEY); Bearer / X-API-Key / form api_key; fail closed
Rate limitIn-process per-IP (+ optional global) on POST → 429
Trusted proxiesOpt-in CIDRs; default ignore X-Forwarded-*
Logginggghstats-style slog: level env + HTTP access level-by-status (4xx warn / 5xx error); not groot logx
Response202 fire-and-forget (+ run_id); HTML or JSON by Accept
Concurrency409 if collect Job active