# Assay > Assay is a ~9 MB static binary that runs Lua scripts in Kubernetes. It replaces 50-250 MB > Python/Node/kubectl containers. One binary handles HTTP, database, crypto, WebSocket, and > Kubernetes-native and AI agent service integrations. No `require()` for builtins — they are global. > Stdlib modules use `require("assay.")` then `M.client(url, opts)` → `c:method()`. > Run `assay context ` to get LLM-ready method signatures for any module. > > Client pattern: `local mod = require("assay.")` → `local c = mod.client(url, opts)` → `c:method()`. > Auth varies: `{token="..."}`, `{api_key="..."}`, `{username="...", password="..."}`. ## Getting Started - [README](https://github.com/developerinlondon/assay/blob/main/README.md): Installation, quick start, examples - [SKILL.md](https://github.com/developerinlondon/assay/blob/main/SKILL.md): LLM agent integration guide - [GitHub](https://github.com/developerinlondon/assay): Source code and issues ## AI Agent & Workflow ### assay.openclaw OpenClaw AI agent platform integration. Invoke tools, send messages, manage state, spawn sub-agents, approval gates, LLM tasks. ```lua local openclaw = require("assay.openclaw") local c = openclaw.client() -- auto-discovers $OPENCLAW_URL + $OPENCLAW_TOKEN -- Invoke any OpenClaw tool local result = c:invoke("message", "send", {target = "#general", message = "Hello!"}) -- Shorthand: send message c:send("discord", "#alerts", "Service is down!") c:notify("ops-team", "Deployment complete") -- Persistent state (JSON files in ~/.openclaw/state/) c:state_set("last-deploy", {version = "1.2.3", time = time()}) local prev = c:state_get("last-deploy") -- Diff detection local diff = c:diff("pr-state", new_snapshot) if diff.changed then log.info("State changed!") end -- LLM task execution local answer = c:llm_task("Summarize this PR", {model = "claude-sonnet"}) -- Cron job management c:cron_add({schedule = "0 9 * * *", task = "daily-report"}) local jobs = c:cron_list() -- Sub-agent spawning c:spawn("Fix the login bug", {model = "gpt-4o"}) -- Approval gates (interactive TTY or structured request) if c:approve("Deploy to production?", context_data) then -- proceed with deployment end ``` ### assay.github GitHub REST API client. PRs, issues, actions, repositories, GraphQL. No `gh` CLI dependency. ```lua local github = require("assay.github") local c = github.client() -- uses $GITHUB_TOKEN -- Pull requests local pr = c.pulls:get("owner/repo", 123) local prs = c.pulls:list("owner/repo", {state = "open", per_page = 10}) local reviews = c.pulls:reviews("owner/repo", 123) c.pulls:merge("owner/repo", 123, {merge_method = "squash"}) -- Issues local issues = c.issues:list("owner/repo", {labels = "bug", state = "open"}) local issue = c.issues:get("owner/repo", 42) c.issues:create("owner/repo", "Bug title", "Description", {labels = {"bug"}}) c.issues:create_note("owner/repo", 42, "Fixed in PR #123") -- Repository info local repo = c.repos:get("owner/repo") -- GitHub Actions local runs = c.runs:list("owner/repo", {status = "completed"}) local run = c.runs:get("owner/repo", 12345) -- GraphQL queries local data = c:graphql("query { viewer { login } }") local complex = c:graphql([[ query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { pullRequests(last: 10, states: OPEN) { nodes { number title author { login } } } } } ]], {owner = "owner", name = "repo"}) ``` ### assay.gmail Gmail REST API with OAuth2 token auto-refresh. Search, read, reply, send emails. ```lua local gmail = require("assay.gmail") local c = gmail.client({ credentials_file = "/path/to/google-oauth2-credentials.json", token_file = "/path/to/saved-oauth2-token.json" }) -- Search emails (Gmail search syntax) local emails = c:search("newer_than:1d is:unread", {max = 20}) local urgent = c:search("subject:urgent OR subject:ASAP", {max = 5}) -- Read a specific message local msg = c:get("message-id-here") -- Reply to an email (preserves thread, references) c:reply("message-id", {body = "Thanks for the update! The fix looks good."}) -- Send new email c:send("user@example.com", "Project Update", [[ Hello team, The deployment completed successfully at 3:00 PM UTC. Best regards, Eda ]]) -- List labels local labels = c:labels() for _, label in ipairs(labels) do log.info("Label: " .. label.name .. " (" .. label.messagesTotal .. " messages)") end ``` ### assay.gcal Google Calendar REST API with OAuth2 token auto-refresh. Events CRUD, calendar list. ```lua local gcal = require("assay.gcal") local c = gcal.client({ credentials_file = "/path/to/google-oauth2-credentials.json", token_file = "/path/to/saved-oauth2-token.json" }) -- List upcoming events local events = c:events({ timeMin = "2026-04-05T00:00:00Z", timeMax = "2026-04-12T00:00:00Z", maxResults = 10 }) -- Get specific event local event = c:event_get("event-id-from-google") -- Create a new meeting local new_event = c:event_create({ summary = "Team standup", description = "Daily sync meeting", start = {dateTime = "2026-04-06T09:00:00Z", timeZone = "UTC"}, ["end"] = {dateTime = "2026-04-06T09:30:00Z", timeZone = "UTC"}, attendees = { {email = "alice@company.com"}, {email = "bob@company.com"} } }) -- Update existing event c:event_update("event-id", { summary = "Team standup (updated agenda)", description = "Daily sync + sprint planning" }) -- Delete event c:event_delete("event-id") -- List all calendars local calendars = c:calendars() for _, cal in ipairs(calendars) do log.info("Calendar: " .. cal.summary .. " (" .. cal.id .. ")") end ``` ### assay.oauth2 Google OAuth2 token management. File-based credentials loading, automatic access token refresh via refresh_token grant, token persistence, and auth header generation. Used internally by gmail and gcal modules. Default credential paths: `~/.config/gog/credentials.json` (OAuth2 client credentials) and `~/.config/gog/token.json` (saved access/refresh tokens). - `M.from_file(credentials_path?, token_path?, opts?)` -> client -- Load OAuth2 credentials and token files. Defaults to `~/.config/gog/credentials.json` and `~/.config/gog/token.json`. Reads `installed` or `web` key from credentials JSON. `opts`: `{token_url}` to override the Google token endpoint. - `client:access_token()` -> string -- Return current access token - `client:refresh()` -> string -- Refresh access token using refresh_token grant. POSTs to `https://oauth2.googleapis.com/token` with client_id, client_secret, and refresh_token. Updates internal state with new access_token, refresh_token, expires_in, and token_type. - `client:save()` -> true -- Persist current token data (including refreshed access_token) back to the token file - `client:headers()` -> table -- Return `{Authorization = "Bearer ", ["Content-Type"] = "application/json"}` for use with http builtins Example: ```lua local oauth2 = require("assay.oauth2") -- Load from default paths local auth = oauth2.from_file() -- Or specify custom paths local auth = oauth2.from_file("/secrets/google-creds.json", "/data/google-token.json") -- Refresh and persist auth:refresh() auth:save() -- Use with http builtins local resp = http.get("https://www.googleapis.com/calendar/v3/calendars/primary/events", { headers = auth:headers() }) ``` ### assay.email_triage Email triage helpers for deterministic categorization or OpenClaw LLM-assisted classification. Sorts emails into three buckets: `needs_reply`, `needs_action`, and `fyi`. Deterministic rules: - `needs_action`: subject contains "action required", "urgent", or "deadline" - `fyi`: from address contains "noreply", "no-reply", "newsletter"; subject contains "newsletter" or "automated"; or `email.automated == true` - `needs_reply`: everything else (human emails that likely need a response) - `M.categorize(emails, opts?)` -> buckets -- Deterministically bucket emails by subject and sender patterns. Returns `{needs_reply = [...], needs_action = [...], fyi = [...]}`. Each email should have `from`, `subject` fields (strings). `opts` is reserved for future use. - `M.categorize_llm(emails, openclaw_client, opts?)` -> buckets -- Use OpenClaw LLM task for smarter bucketing. Requires an `openclaw_client` with `llm_task` method. Returns same bucket structure. `opts`: `{prompt, output_schema}` to customize the LLM classification prompt and expected JSON schema. Example: ```lua local email_triage = require("assay.email_triage") -- Deterministic categorization (no LLM, no network) local emails = { {from = "alice@company.com", subject = "Review PR #42"}, {from = "noreply@github.com", subject = "CI build passed"}, {from = "boss@company.com", subject = "Action required: quarterly report"}, } local buckets = email_triage.categorize(emails) -- buckets.needs_reply = [{from="alice@...", subject="Review PR #42"}] -- buckets.needs_action = [{from="boss@...", subject="Action required: ..."}] -- buckets.fyi = [{from="noreply@...", subject="CI build passed"}] -- LLM-assisted triage via OpenClaw local openclaw = require("assay.openclaw") local oc = openclaw.client() local smart_buckets = email_triage.categorize_llm(emails, oc) ``` ## assay.alertmanager Alertmanager alert and silence management. Query, create, and delete alerts and silences. Client: `alertmanager.client(url)`. - `c.alerts:list(opts?)` → [alert] — List alerts. `opts`: `{active, silenced, inhibited, unprocessed, filter, receiver}` - `c.alerts:post(alerts)` → true — Post new alerts (array of alert objects) - `c.alerts:groups(opts?)` → [group] — List alert groups. `opts`: `{active, silenced, inhibited, filter, receiver}` - `c.alerts:is_firing(alertname)` → bool — Check if a specific alert is currently firing - `c.alerts:active_count()` → number — Count active non-silenced, non-inhibited alerts - `c.silences:list(opts?)` → [silence] — List silences. `opts`: `{filter}` - `c.silences:get(id)` → silence — Get silence by ID - `c.silences:create(silence)` → `{silenceID}` — Create a silence - `c.silences:delete(id)` → true — Delete silence by ID - `c.silences:silence_alert(alertname, duration_hours, opts?)` → silenceID — Silence an alert by name for N hours. `opts`: `{created_by, comment}` - `c.status:get()` → `{cluster, config}` — Get Alertmanager status and cluster info - `c.receivers:list()` → [receiver] — List notification receivers Example: ```lua local am = require("assay.alertmanager") local c = am.client("http://alertmanager:9093") local firing = c.alerts:is_firing("HighCPU") if firing then c.silences:silence_alert("HighCPU", 2, {comment = "Investigating"}) end ``` ## assay.argocd ArgoCD GitOps application management. Apps, sync, health, projects, repositories, clusters. Client: `argocd.client(url, {token="..."})` or `{username="...", password="..."}`. - `c.apps:list(opts?)` → [app] — List applications. `opts`: `{project, selector}` - `c.apps:get(name)` → app — Get application by name - `c.apps:health(name)` → `{status, sync, message}` — Get app health and sync status - `c.apps:sync(name, opts?)` → result — Trigger sync. `opts`: `{revision, prune, dry_run, strategy}` - `c.apps:refresh(name, opts?)` → app — Refresh app state. `opts.type`: `"normal"` (default) or `"hard"` - `c.apps:rollback(name, id)` → result — Rollback to history ID - `c.apps:resources(name)` → resource_tree — Get application resource tree - `c.apps:manifests(name, opts?)` → manifests — Get manifests. `opts`: `{revision}` - `c.apps:delete(name, opts?)` → nil — Delete app. `opts`: `{cascade, propagation_policy}` - `c.apps:is_healthy(name)` → bool — Check if app health status is "Healthy" - `c.apps:is_synced(name)` → bool — Check if app sync status is "Synced" - `c.apps:wait_healthy(name, timeout_secs)` → true — Wait for app to become healthy, errors on timeout - `c.apps:wait_synced(name, timeout_secs)` → true — Wait for app to become synced, errors on timeout - `c.projects:list()` → [project] — List projects - `c.projects:get(name)` → project — Get project by name - `c.repositories:list()` → [repo] — List repositories - `c.repositories:get(repo_url)` → repo — Get repository by URL - `c.clusters:list()` → [cluster] — List clusters - `c.clusters:get(server_url)` → cluster — Get cluster by server URL - `c.settings:get()` → settings — Get ArgoCD settings - `c:version()` → version — Get ArgoCD version info Example: ```lua local argocd = require("assay.argocd") local c = argocd.client("https://argocd.example.com", {token = env.get("ARGOCD_TOKEN")}) c.apps:sync("my-app", {prune = true}) c.apps:wait_healthy("my-app", 120) ``` ## assert Assertion utilities. No `require()` needed. All raise `error()` on failure. - `assert.eq(a, b, msg?)` — Assert `a == b` - `assert.ne(a, b, msg?)` — Assert `a ~= b` - `assert.gt(a, b, msg?)` — Assert `a > b` - `assert.lt(a, b, msg?)` — Assert `a < b` - `assert.contains(str, sub, msg?)` — Assert string contains substring - `assert.not_nil(val, msg?)` — Assert value is not nil - `assert.matches(str, pattern, msg?)` — Assert string matches regex pattern ## async Async task management. No `require()` needed. - `async.spawn(fn)` → handle — Spawn async task, returns handle - `async.spawn_interval(fn, ms)` → handle — Spawn recurring task every `ms` milliseconds - `handle:await()` → result — Wait for task completion, returns result - `handle:cancel()` → nil — Cancel recurring task ## assay.certmanager cert-manager certificate lifecycle. Certificates, issuers, ACME orders and challenges. Client: `certmanager.client(url, token)`. ### Certificates - `c.certificates:list(namespace)` -> `{items}` -- List certificates in namespace - `c.certificates:get(namespace, name)` -> cert|nil -- Get certificate by name - `c.certificates:status(namespace, name)` -> `{ready, not_after, not_before, renewal_time, revision, conditions}` -- Get status - `c.certificates:is_ready(namespace, name)` -> bool -- Check if certificate has Ready=True condition - `c.certificates:wait_ready(namespace, name, timeout_secs?)` -> true -- Wait for readiness. Default 300s. - `c.certificates:all_ready(namespace)` -> `{ready, not_ready, total, not_ready_names}` -- Check all certificates ### Issuers - `c.issuers:list(namespace)` -> `{items}` -- List issuers in namespace - `c.issuers:get(namespace, name)` -> issuer|nil -- Get issuer by name - `c.issuers:is_ready(namespace, name)` -> bool -- Check if issuer is ready - `c.issuers:all_ready(namespace)` -> `{ready, not_ready, total, not_ready_names}` -- Check all issuers ### ClusterIssuers - `c.cluster_issuers:list()` -> `{items}` -- List cluster-scoped issuers - `c.cluster_issuers:get(name)` -> issuer|nil -- Get cluster issuer by name - `c.cluster_issuers:is_ready(name)` -> bool -- Check if cluster issuer is ready ### Certificate Requests - `c.requests:list(namespace)` -> `{items}` -- List certificate requests - `c.requests:get(namespace, name)` -> request|nil -- Get certificate request - `c.requests:is_approved(namespace, name)` -> bool -- Check if request is approved ### ACME Orders & Challenges - `c.orders:list(namespace)` -> `{items}` -- List ACME orders - `c.orders:get(namespace, name)` -> order|nil -- Get ACME order - `c.challenges:list(namespace)` -> `{items}` -- List ACME challenges - `c.challenges:get(namespace, name)` -> challenge|nil -- Get ACME challenge Example: ```lua local cm = require("assay.certmanager") local c = cm.client("https://k8s-api:6443", env.get("K8S_TOKEN")) c.certificates:wait_ready("default", "my-tls-cert", 600) local status = c.certificates:all_ready("default") assert.eq(status.not_ready, 0) ``` ## assay.crossplane Crossplane infrastructure management. Providers, XRDs, compositions, managed resources. Client: `crossplane.client(url, token)`. ### Providers - `c.providers:list()` -> `{items}` -- List all providers - `c.providers:get(name)` -> provider|nil -- Get provider by name - `c.providers:is_healthy(name)` -> bool -- Check if provider has Healthy=True condition - `c.providers:is_installed(name)` -> bool -- Check if provider has Installed=True condition - `c.providers:status(name)` -> `{installed, healthy, current_revision, conditions}` -- Get full provider status - `c.providers:all_healthy()` -> `{healthy, unhealthy, total, unhealthy_names}` -- Check all providers health ### Provider Revisions - `c.provider_revisions:list()` -> `{items}` -- List provider revisions - `c.provider_revisions:get(name)` -> revision|nil -- Get provider revision by name ### Configurations - `c.configurations:list()` -> `{items}` -- List configurations - `c.configurations:get(name)` -> config|nil -- Get configuration by name - `c.configurations:is_healthy(name)` -> bool -- Check if configuration is healthy - `c.configurations:is_installed(name)` -> bool -- Check if configuration is installed ### Functions - `c.functions:list()` -> `{items}` -- List composition functions - `c.functions:get(name)` -> function|nil -- Get function by name - `c.functions:is_healthy(name)` -> bool -- Check if function is healthy ### Composite Resource Definitions (XRDs) - `c.xrds:list()` -> `{items}` -- List all XRDs - `c.xrds:get(name)` -> xrd|nil -- Get XRD by name - `c.xrds:is_established(name)` -> bool -- Check if XRD has Established=True condition - `c.xrds:all_established()` -> `{established, not_established, total}` -- Check all XRDs status ### Compositions - `c.compositions:list()` -> `{items}` -- List all compositions - `c.compositions:get(name)` -> composition|nil -- Get composition by name ### Managed Resources - `c.managed_resources:get(api_group, version, kind, name)` -> resource|nil -- Get managed resource - `c.managed_resources:is_ready(api_group, version, kind, name)` -> bool -- Check if managed resource has Ready=True - `c.managed_resources:list(api_group, version, kind)` -> `{items}` -- List managed resources Example: ```lua local crossplane = require("assay.crossplane") local c = crossplane.client("https://k8s-api:6443", env.get("K8S_TOKEN")) local status = c.providers:all_healthy() assert.eq(status.unhealthy, 0, "Unhealthy providers: " .. table.concat(status.unhealthy_names, ", ")) ``` ## crypto Cryptography utilities. No `require()` needed. - `crypto.jwt_sign(claims, key, alg, opts?)` → string — Sign JWT token - `claims`: table with `{iss, sub, exp, ...}` — standard JWT claims - `key`: string — signing key (secret or PEM private key) - `alg`: `"HS256"` | `"HS384"` | `"HS512"` | `"RS256"` | `"RS384"` | `"RS512"` - `opts`: `{kid = "key-id"}` — optional key ID header - `crypto.jwt_decode(token)` → `{header, claims}` — Decode a JWT WITHOUT verifying its signature - Returns `header` and `claims` parsed from the base64url segments - Use when the JWT travels through a trusted channel (your own session cookie over TLS) and you just need to read the claims - For untrusted JWTs, verify the signature with a JWKS-aware verifier instead - `crypto.hash(str, alg)` → string — Hash string (hex output) - `alg`: `"sha256"` | `"sha384"` | `"sha512"` | `"md5"` - `crypto.hmac(key, data, alg?, raw?)` → string — HMAC signature - `alg`: `"sha256"` (default) | `"sha384"` | `"sha512"` - `raw`: `true` for binary output, `false` (default) for hex - `crypto.random(len)` → string — Secure random hex string of `len` bytes ## base64 Base64 encoding. No `require()` needed. - `base64.encode(str)` → string — Base64 encode - `base64.decode(str)` → string — Base64 decode ## db SQL database access. No `require()` needed. Supports Postgres, MySQL, SQLite via connection URL. - `db.connect(url)` → conn — Connect to database - URLs: `postgres://user:pass@host:5432/db`, `mysql://user:pass@host:3306/db`, `sqlite:///path.db` - `db.query(conn, sql, params?)` → [row] — Execute query, return rows as tables - Parameterized: `db.query(conn, "SELECT * FROM users WHERE id = $1", {42})` - `db.execute(conn, sql, params?)` → number — Execute statement, return affected row count - `db.close(conn)` → nil — Close database connection ## assay.dex Dex OIDC identity provider. Discovery, JWKS, health, and configuration validation. Client: `dex.client(url)`. Module-level functions also available for backward compatibility. ### Discovery (`c.discovery`) - `c.discovery:config()` → `{issuer, authorization_endpoint, token_endpoint, jwks_uri, ...}` — Get OIDC discovery configuration - `c.discovery:jwks()` → `{keys}` — Get JSON Web Key Set (fetches jwks_uri from discovery) - `c.discovery:issuer()` → string — Get issuer URL from discovery - `c.discovery:has_endpoint(endpoint_name)` → bool — Check if endpoint exists in discovery doc ### Health (`c.health`) - `c.health:check()` → bool — Check Dex health via `/healthz` - `c.health:ready()` → bool — Check Dex readiness (alias for check) ### Scopes (`c.scopes`) - `c.scopes:list()` → [string] — List supported OIDC scopes - `c.scopes:supports(scope)` → bool — Check if a specific scope is supported ### Grants (`c.grants`) - `c.grants:list()` → [string] — List supported grant types - `c.grants:supports(grant_type)` → bool — Check if a specific grant type is supported - `c.grants:response_types()` → [string] — List supported response types ### Top-level - `c:validate_config()` → `{ok, errors}` — Validate OIDC configuration completeness (checks issuer, endpoints, jwks_uri) - `c:admin_version()` → version|nil — Get Dex admin API version (nil if unavailable) ### Backward Compatibility All legacy module-level functions (`M.discovery(url)`, `M.health(url)`, `M.supported_scopes(url)`, etc.) remain available and delegate to the client sub-objects. Example: ```lua local dex = require("assay.dex") -- New client sub-object style local c = dex.client("http://dex:5556") assert.eq(c.health:check(), true, "Dex not healthy") local validation = c:validate_config() assert.eq(validation.ok, true, "OIDC config invalid: " .. table.concat(validation.errors, ", ")) local scopes = c.scopes:list() assert.eq(c.scopes:supports("openid"), true) -- Legacy module-level style still works assert.eq(dex.health("http://dex:5556"), true, "Dex not healthy") local validation = dex.validate_config("http://dex:5556") ``` ## assay.eso External Secrets Operator. ExternalSecrets, SecretStores, ClusterSecretStores sync status. Client: `eso.client(url, token)`. ### ExternalSecrets - `c.external_secrets:list(namespace)` -> `{items}` -- List ExternalSecrets - `c.external_secrets:get(namespace, name)` -> es|nil -- Get ExternalSecret by name - `c.external_secrets:status(namespace, name)` -> `{ready, status, sync_hash, conditions}` -- Get sync status - `c.external_secrets:is_synced(namespace, name)` -> bool -- Check if ExternalSecret is synced (Ready=True) - `c.external_secrets:wait_synced(namespace, name, timeout_secs?)` -> true -- Wait for sync. Default 60s. - `c.external_secrets:all_synced(namespace)` -> `{synced, failed, total, failed_names}` -- Check all ExternalSecrets ### SecretStores - `c.secret_stores:list(namespace)` -> `{items}` -- List SecretStores in namespace - `c.secret_stores:get(namespace, name)` -> store|nil -- Get SecretStore by name - `c.secret_stores:status(namespace, name)` -> `{ready, conditions}` -- Get store status - `c.secret_stores:is_ready(namespace, name)` -> bool -- Check if SecretStore is ready - `c.secret_stores:all_ready(namespace)` -> `{ready, not_ready, total, not_ready_names}` -- Check all SecretStores ### ClusterSecretStores - `c.cluster_secret_stores:list()` -> `{items}` -- List cluster-scoped SecretStores - `c.cluster_secret_stores:get(name)` -> store|nil -- Get ClusterSecretStore by name - `c.cluster_secret_stores:is_ready(name)` -> bool -- Check if ClusterSecretStore is ready ### ClusterExternalSecrets - `c.cluster_external_secrets:list()` -> `{items}` -- List ClusterExternalSecrets - `c.cluster_external_secrets:get(name)` -> es|nil -- Get ClusterExternalSecret by name Example: ```lua local eso = require("assay.eso") local c = eso.client("https://k8s-api:6443", env.get("K8S_TOKEN")) c.external_secrets:wait_synced("default", "my-external-secret", 120) local status = c.external_secrets:all_synced("default") assert.eq(status.failed, 0) ``` ## assay.flux Flux CD GitOps toolkit. GitRepositories, Kustomizations, HelmReleases, notifications, image automation. Client: `flux.client(url, token)`. ### Git Repositories - `c.git_repos:list(namespace)` -> `{items}` -- List GitRepositories - `c.git_repos:get(namespace, name)` -> repo|nil -- Get GitRepository by name (nil if 404) - `c.git_repos:is_ready(namespace, name)` -> bool -- Check if GitRepository has Ready=True condition ### Helm Repositories - `c.helm_repos:list(namespace)` -> `{items}` -- List HelmRepositories - `c.helm_repos:get(namespace, name)` -> repo|nil -- Get HelmRepository by name - `c.helm_repos:is_ready(namespace, name)` -> bool -- Check if HelmRepository is ready ### Helm Charts - `c.helm_charts:list(namespace)` -> `{items}` -- List HelmCharts ### OCI Repositories - `c.oci_repos:list(namespace)` -> `{items}` -- List OCIRepositories ### Kustomizations - `c.kustomizations:list(namespace)` -> `{items}` -- List Kustomizations - `c.kustomizations:get(namespace, name)` -> ks|nil -- Get Kustomization by name - `c.kustomizations:is_ready(namespace, name)` -> bool -- Check if Kustomization is ready - `c.kustomizations:status(namespace, name)` -> `{ready, revision, last_applied_revision, conditions}`|nil -- Get status - `c.kustomizations:all_ready(namespace)` -> `{ready, not_ready, total, not_ready_names}` -- Check all Kustomizations ### Helm Releases - `c.helm_releases:list(namespace)` -> `{items}` -- List HelmReleases - `c.helm_releases:get(namespace, name)` -> hr|nil -- Get HelmRelease by name - `c.helm_releases:is_ready(namespace, name)` -> bool -- Check if HelmRelease is ready - `c.helm_releases:status(namespace, name)` -> `{ready, revision, last_applied_revision, conditions}`|nil -- Get status - `c.helm_releases:all_ready(namespace)` -> `{ready, not_ready, total, not_ready_names}` -- Check all HelmReleases ### Notifications - `c.notifications:alerts(namespace)` -> `{items}` -- List notification alerts - `c.notifications:providers(namespace)` -> `{items}` -- List notification providers - `c.notifications:receivers(namespace)` -> `{items}` -- List notification receivers ### Image Policies - `c.image_policies:list(namespace)` -> `{items}` -- List image automation policies ### Sources (aggregate) - `c.sources:all_ready(namespace)` -> `{ready, not_ready, total, not_ready_names}` -- Check all Git+Helm sources Example: ```lua local flux = require("assay.flux") local c = flux.client("https://k8s-api:6443", env.get("K8S_TOKEN")) local status = c.kustomizations:all_ready("flux-system") assert.eq(status.not_ready, 0, "Some Kustomizations not ready: " .. table.concat(status.not_ready_names, ", ")) ``` ## fs Filesystem operations. No `require()` needed. ### Reading & Writing - `fs.read(path)` → string — Read entire file as UTF-8 text - `fs.read_bytes(path)` → string — Read entire file as raw bytes (binary-safe) - `fs.write(path, str)` → nil — Write UTF-8 string to file (creates parent dirs) - `fs.write_bytes(path, data)` → nil — Write raw bytes to file (binary-safe, creates parent dirs) ### File Operations - `fs.remove(path)` → nil — Remove file or directory (recursive for dirs) - `fs.copy(src, dst)` → bytes_copied — Copy file - `fs.rename(src, dst)` → nil — Move/rename file or directory - `fs.chmod(path, mode)` → nil — Set file permissions (e.g. `"755"`) ### Directory Operations - `fs.mkdir(path)` → nil — Create directory (and parents) - `fs.list(path)` → `[{name, type}]` — List directory entries. `type`: `"file"`, `"directory"`, `"symlink"` - `fs.readdir(path, opts?)` → `[{name, path, type}]` — Recursive directory listing. `opts`: `{depth = N}` for max recursion - `fs.glob(pattern)` → `[path]` — Glob pattern matching, returns array of path strings - `fs.tempdir()` → path — Create a temporary directory ### Metadata - `fs.stat(path)` → `{size, type, modified, created, permissions}` — File metadata - `fs.exists(path)` → bool — Check if path exists ### Binary I/O `fs.read_bytes` and `fs.write_bytes` handle files with arbitrary byte content (images, WASM, protobuf, compressed data). Lua strings can hold any bytes, so the returned value works with `http.serve()` response bodies, `base64.encode()`, or any other builtin that accepts strings. ```lua -- Copy a binary file local data = fs.read_bytes("image.png") fs.write_bytes("copy.png", data) -- Serve binary files via http.serve() http.serve(8080, { GET = { ["/*"] = function(req) return { status = 200, body = fs.read_bytes("static" .. req.path) } end, }, }) ``` ## assay.gitlab GitLab REST API v4 client. Projects, repositories, commits, merge requests, pipelines, jobs, issues, releases, groups, container registry, webhooks, environments, and deploy tokens. Supports both private access token (`PRIVATE-TOKEN` header) and OAuth2 bearer authentication. ```lua local gitlab = require("assay.gitlab") local c = gitlab.client("https://gitlab.example.com", { token = "glpat-xxxx" }) ``` ### c.projects - `c.projects:list(opts?)` -> `[project]` — List projects. Options: `search`, `order_by`, `sort`, `per_page`, `page` - `c.projects:get(id)` -> `project|nil` — Get project by numeric ID or `"namespace/name"` path ### c.files - `c.files:get(project, path, opts?)` -> `table|nil` — Get file metadata (base64-encoded content). Options: `ref` (default: `"main"`) - `c.files:raw(project, path, opts?)` -> `string|nil` — Get raw file content as string. Options: `ref` (default: `"main"`) - `c.files:create(project, path, opts)` -> `table` — Create file. Options: `branch`, `content`, `commit_message` - `c.files:update(project, path, opts)` -> `table` — Update file. Options: `branch`, `content`, `commit_message` - `c.files:delete(project, path, opts)` -> `nil` — Delete file. Options: `branch`, `commit_message` ### c.repository - `c.repository:tree(project, opts?)` -> `[entry]` — List repository tree. Options: `path`, `ref`, `recursive`, `per_page` - `c.repository:compare(project, from, to)` -> `{commits, diffs}` — Compare branches, tags, or commits ### c.commits - `c.commits:list(project, opts?)` -> `[commit]` — List commits. Options: `ref_name`, `since`, `until`, `path`, `per_page` - `c.commits:get(project, sha)` -> `commit|nil` — Get single commit by SHA - `c.commits:create(project, opts)` -> `commit` — Atomic multi-file commit. Options: `branch`, `commit_message`, `actions` (array of `{action, file_path, content}`) - `c.commits:cherry_pick(project, sha, opts)` -> `commit` — Cherry-pick commit. Options: `branch` ### c.branches - `c.branches:list(project, opts?)` -> `[branch]` — List branches. Options: `search`, `per_page` - `c.branches:get(project, name)` -> `branch|nil` — Get branch by name - `c.branches:create(project, opts)` -> `branch` — Create branch. Options: `branch`, `ref` - `c.branches:delete(project, name)` -> `nil` — Delete branch ### c.tags - `c.tags:list(project, opts?)` -> `[tag]` — List tags. Options: `search`, `order_by`, `sort` - `c.tags:get(project, name)` -> `tag|nil` — Get tag by name - `c.tags:create(project, opts)` -> `tag` — Create tag. Options: `tag_name`, `ref`, `message` - `c.tags:delete(project, name)` -> `nil` — Delete tag ### c.merge_requests - `c.merge_requests:list(project, opts?)` -> `[mr]` — List MRs. Options: `state`, `order_by`, `sort`, `labels`, `per_page` - `c.merge_requests:get(project, iid)` -> `mr|nil` — Get MR by IID - `c.merge_requests:create(project, opts)` -> `mr` — Create MR. Options: `source_branch`, `target_branch`, `title`, `description` - `c.merge_requests:update(project, iid, opts)` -> `mr` — Update MR. Options: `title`, `description`, `state_event`, `labels` - `c.merge_requests:merge(project, iid, opts?)` -> `mr` — Accept (merge) MR. Options: `squash`, `merge_commit_message`, `should_remove_source_branch` - `c.merge_requests:approve(project, iid)` -> `table` — Approve MR - `c.merge_requests:changes(project, iid)` -> `mr` — Get MR with diff changes - `c.merge_requests:notes(project, iid, opts?)` -> `[note]` — List MR comments - `c.merge_requests:create_note(project, iid, body)` -> `note` — Add comment to MR ### c.pipelines - `c.pipelines:list(project, opts?)` -> `[pipeline]` — List pipelines. Options: `ref`, `status`, `per_page` - `c.pipelines:get(project, id)` -> `pipeline|nil` — Get pipeline by ID - `c.pipelines:create(project, opts)` -> `pipeline` — Trigger pipeline. Options: `ref`, `variables` - `c.pipelines:cancel(project, id)` -> `pipeline` — Cancel running pipeline - `c.pipelines:retry(project, id)` -> `pipeline` — Retry failed pipeline - `c.pipelines:delete(project, id)` -> `nil` — Delete pipeline - `c.pipelines:jobs(project, pipeline_id, opts?)` -> `[job]` — List jobs for a pipeline ### c.jobs - `c.jobs:list(project, opts?)` -> `[job]` — List all project jobs. Options: `scope` (array of statuses) - `c.jobs:get(project, id)` -> `job|nil` — Get job by ID - `c.jobs:retry(project, id)` -> `job` — Retry a job - `c.jobs:cancel(project, id)` -> `job` — Cancel a job - `c.jobs:log(project, id)` -> `string|nil` — Get job trace/log output as raw text ### c.releases - `c.releases:list(project, opts?)` -> `[release]` — List releases. Options: `per_page` - `c.releases:get(project, tag_name)` -> `release|nil` — Get release by tag name - `c.releases:create(project, opts)` -> `release` — Create release. Options: `tag_name`, `name`, `description` - `c.releases:update(project, tag_name, opts)` -> `release` — Update release - `c.releases:delete(project, tag_name)` -> `nil` — Delete release ### c.issues - `c.issues:list(project, opts?)` -> `[issue]` — List issues. Pass `nil` as project for global issues. Options: `state`, `labels`, `search`, `per_page` - `c.issues:get(project, iid)` -> `issue|nil` — Get issue by IID - `c.issues:create(project, opts)` -> `issue` — Create issue. Options: `title`, `description`, `labels`, `assignee_ids` - `c.issues:update(project, iid, opts)` -> `issue` — Update issue. Options: `title`, `description`, `state_event`, `labels` - `c.issues:notes(project, iid, opts?)` -> `[note]` — List issue comments - `c.issues:create_note(project, iid, body)` -> `note` — Add comment to issue ### c.groups - `c.groups:list(opts?)` -> `[group]` — List groups. Options: `search`, `per_page` - `c.groups:get(id)` -> `group|nil` — Get group by numeric ID or `"path"` name - `c.groups:projects(id, opts?)` -> `[project]` — List projects in a group ### c.registry - `c.registry:repositories(project)` -> `[repo]` — List container registry repositories - `c.registry:tags(project, repo_id)` -> `[tag]` — List tags for a registry repository - `c.registry:tag(project, repo_id, tag_name)` -> `tag|nil` — Get single registry tag (with digest, size) - `c.registry:delete_tag(project, repo_id, tag_name)` -> `nil` — Delete a registry tag ### c.hooks - `c.hooks:list(project)` -> `[hook]` — List project hooks - `c.hooks:get(project, id)` -> `hook|nil` — Get hook by ID - `c.hooks:create(project, opts)` -> `hook` — Create hook. Options: `url`, `push_events`, `merge_requests_events`, etc. - `c.hooks:update(project, id, opts)` -> `hook` — Update hook - `c.hooks:delete(project, id)` -> `nil` — Delete hook ### c.users - `c.users:current()` -> `user` — Get authenticated user - `c.users:list(opts?)` -> `[user]` — Search users. Options: `username`, `search`, `per_page` ### c.environments - `c.environments:list(project, opts?)` -> `[env]` — List environments. Options: `search` - `c.environments:get(project, id)` -> `env|nil` — Get environment by ID ### c.deploy_tokens - `c.deploy_tokens:list(project)` -> `[token]` — List deploy tokens - `c.deploy_tokens:create(project, opts)` -> `token` — Create token. Options: `name`, `scopes`, `expires_at` - `c.deploy_tokens:delete(project, id)` -> `nil` — Delete token ### Example: Atomic Multi-File Commit ```lua local gitlab = require("assay.gitlab") local c = gitlab.client("https://gitlab.example.com", { token = env.get("GITLAB_TOKEN") }) local result = c.commits:create(42, { branch = "main", commit_message = "Update config for v2.0", actions = { { action = "update", file_path = "config/app.yaml", content = "version: 2.0\n" }, { action = "update", file_path = "config/db.yaml", content = "pool_size: 20\n" }, }, }) log.info("Committed: " .. result.short_id) ``` ### Example: Create and Merge an MR ```lua local gitlab = require("assay.gitlab") local c = gitlab.client("https://gitlab.example.com", { token = env.get("GITLAB_TOKEN") }) -- Create branch c.branches:create(42, { branch = "feat/update", ref = "main" }) -- Commit changes c.commits:create(42, { branch = "feat/update", commit_message = "Update dependencies", actions = { { action = "update", file_path = "package.json", content = '{"version": "2.0.0"}' }, }, }) -- Open MR local mr = c.merge_requests:create(42, { source_branch = "feat/update", target_branch = "main", title = "Update dependencies to v2.0", }) -- Approve and merge c.merge_requests:approve(42, mr.iid) c.merge_requests:merge(42, mr.iid, { squash = true, should_remove_source_branch = true }) ``` ## assay.grafana Grafana monitoring and dashboards. Health, datasources, annotations, alerts, folders. Client: `grafana.client(url, {api_key="..."})` or `{username="...", password="..."}`. - `c:health()` → `{database, version, commit}` — Check Grafana server health - `c:datasources()` → `[{id, name, type, url}]` — List all datasources - `c:datasource(id_or_uid)` → `{id, name, type, ...}` — Get datasource by numeric ID or string UID - `c:search(opts?)` → `[{id, title, type}]` — Search dashboards/folders. `opts`: `{query, type, tag, limit}` - `c:dashboard(uid)` → `{dashboard, meta}` — Get dashboard by UID - `c:annotations(opts?)` → `[{id, text, time}]` — List annotations. `opts`: `{from, to, dashboard_id, limit, tags}` - `c:create_annotation(annotation)` → `{id}` — Create annotation. `annotation`: `{text, dashboardId?, tags?}` - `c:org()` → `{id, name}` — Get current organization - `c:alert_rules()` → `[{uid, title}]` — List provisioned alert rules - `c:folders()` → `[{id, uid, title}]` — List all folders Example: ```lua local grafana = require("assay.grafana") local c = grafana.client("http://grafana:3000", {api_key = "glsa_..."}) local h = c:health() assert.eq(h.database, "ok") ``` ## assay.harbor Harbor container registry. Projects, repositories, artifacts, vulnerability scanning. Client: `harbor.client(url, {api_key="..."})` or `{username="...", password="..."}`. ### System (`c.system`) - `c.system:health()` → `{status, components}` — Check Harbor health - `c.system:info()` → `{harbor_version, ...}` — Get system information - `c.system:statistics()` → `{private_project_count, ...}` — Get registry statistics - `c.system:is_healthy()` → bool — Check if all components report "healthy" ### Projects (`c.projects`) - `c.projects:list(opts?)` → [project] — List projects. `opts`: `{name, public, page, page_size}` - `c.projects:get(name_or_id)` → project — Get project by name or numeric ID ### Repositories (`c.repositories`) - `c.repositories:list(project_name, opts?)` → [repo] — List repos. `opts`: `{page, page_size, q}` - `c.repositories:get(project_name, repo_name)` → repo — Get repository ### Artifacts (`c.artifacts`) - `c.artifacts:list(project_name, repo_name, opts?)` → [artifact] — List artifacts. `opts`: `{page, page_size, with_tag, with_scan_overview}` - `c.artifacts:get(project_name, repo_name, reference)` → artifact — Get artifact by tag or digest - `c.artifacts:tags(project_name, repo_name, reference)` → [tag] — List artifact tags - `c.artifacts:exists(project_name, repo_name, tag)` → bool — Check if image tag exists - `c.artifacts:latest(project_name, repo_name)` → artifact|nil — Get most recent artifact ### Scan (`c.scan`) - `c.scan:trigger(project_name, repo_name, reference)` → true — Trigger vulnerability scan (async) - `c.scan:vulnerabilities(project_name, repo_name, reference)` → `{total, fixable, critical, high, medium, low, negligible}`|nil — Get vulnerability summary ### Replication (`c.replication`) - `c.replication:policies()` → [policy] — List replication policies - `c.replication:executions(opts?)` → [execution] — List replication executions. `opts`: `{policy_id}` ### Backward Compatibility All legacy colon-style methods (`c:health()`, `c:projects()`, `c:artifacts()`, etc.) remain available and delegate to the sub-objects above. Example: ```lua local harbor = require("assay.harbor") local c = harbor.client("https://harbor.example.com", {username = "admin", password = env.get("HARBOR_PASS")}) -- New sub-object style assert.eq(c.system:is_healthy(), true, "Harbor unhealthy") c.scan:trigger("myproject", "myapp", "latest") sleep(30) local vulns = c.scan:vulnerabilities("myproject", "myapp", "latest") assert.eq(vulns.critical, 0, "Critical vulnerabilities found!") -- Legacy style still works assert.eq(c:is_healthy(), true, "Harbor unhealthy") c:scan_artifact("myproject", "myapp", "latest") ``` ## assay.healthcheck HTTP health checking utilities. Status codes, JSON path, body matching, latency, multi-check. Module-level functions (no client needed): `M.function(url, ...)`. - `M.http(url, opts?)` → `{ok, status, latency_ms, error?}` — HTTP health check. `opts`: `{expected_status, method, body, headers}`. Default expects 200. - `M.json_path(url, path_expr, expected, opts?)` → `{ok, actual, expected, error?}` — Check JSON response field. Dot-notation path: `"data.status"`. - `M.status_code(url, expected, opts?)` → `{ok, status, error?}` — Check specific HTTP status code - `M.body_contains(url, pattern, opts?)` → `{ok, found, error?}` — Check if response body contains literal pattern - `M.endpoint(url, opts?)` → `{ok, status, latency_ms, error?}` — Check status and latency. `opts`: `{max_latency_ms, expected_status, headers}` - `M.multi(checks)` → `{ok, results, passed, failed, total}` — Run multiple checks. `checks`: `[{name, check=function}]` - `M.wait(url, opts?)` → `{ok, status, attempts}` — Wait for endpoint to become healthy. `opts`: `{timeout, interval, expect_status, headers}`. Default 60s timeout. Example: ```lua local hc = require("assay.healthcheck") local result = hc.multi({ {name = "api", check = function() return hc.http("http://api:8080/health") end}, {name = "db-field", check = function() return hc.json_path("http://api:8080/health", "database", "ok") end}, {name = "latency", check = function() return hc.endpoint("http://api:8080/health", {max_latency_ms = 500}) end}, }) assert.eq(result.ok, true, result.failed .. " health checks failed") ``` ## http HTTP client and server. No `require()` needed. All responses return `{status, body, headers}`. Options table supports `{headers = {["X-Key"] = "value"}}`. - `http.get(url, opts?)` → `{status, body, headers}` — GET request - `http.post(url, body, opts?)` → `{status, body, headers}` — POST request (auto-JSON if table body) - `http.put(url, body, opts?)` → `{status, body, headers}` — PUT request - `http.patch(url, body, opts?)` → `{status, body, headers}` — PATCH request - `http.delete(url, opts?)` → `{status, body, headers}` — DELETE request - `http.serve(port, routes)` → blocks — Start HTTP server with async handlers - Routes: `{GET = {["/path"] = function(req) return {status=200, body="ok"} end}}` - Handlers receive `{method, path, body, headers, query}`, return `{status, body, json?, headers?}` - Handlers can call async builtins (`http.get`, `sleep`, etc.) - Header values can be a string or an array of strings. Array values emit the same header name multiple times — required for `Set-Cookie` with multiple cookies, and useful for `Link`, `Vary`, `Cache-Control`, etc.: ```lua return { status = 200, headers = { ["Set-Cookie"] = { "session=abc; Path=/; HttpOnly", "csrf=xyz; Path=/", }, }, body = "ok", } ``` - SSE: return `{sse = function(send) send({event="update", data="hello", id="1"}) end}` - `send()` accepts: `event`, `data`, `id`, `retry` fields - Sets `Content-Type: text/event-stream` automatically - Stream closes when function returns ## assay.k8s Kubernetes API client. 30+ resource types, CRDs, readiness checks, pod logs, rollouts. Module-level functions: auto-discovers cluster API via `KUBERNETES_SERVICE_HOST` env var. Auth: uses service account token from `/var/run/secrets/kubernetes.io/serviceaccount/token`. All functions accept optional `opts` with `{base_url, token}` overrides. Supported kinds: pod, service, secret, configmap, endpoints, serviceaccount, persistentvolumeclaim (pvc), limitrange, resourcequota, event, namespace, node, persistentvolume (pv), deployment, statefulset, daemonset, replicaset, job, cronjob, ingress, ingressclass, networkpolicy, storageclass, role, rolebinding, clusterrole, clusterrolebinding, hpa, poddisruptionbudget (pdb). ### CRD Registration - `M.register_crd(kind, api_group, version, plural, cluster_scoped?)` — Register custom resource for use with get/list/create ### Raw HTTP Verbs - `M.get(path, opts?)` → resource — Raw GET any K8s API path - `M.post(path, body, opts?)` → resource — Raw POST to any K8s API path - `M.put(path, body, opts?)` → resource — Raw PUT to any K8s API path - `M.patch(path, body, opts?)` → resource — Raw PATCH any K8s API path. `opts.content_type` defaults to merge-patch. - `M.delete(path, opts?)` → nil — Raw DELETE any K8s API path ### Resources (`M.resources`) Generic CRUD operations for any resource kind. - `M.resources:get(namespace, kind, name, opts?)` → resource — Get resource by kind and name - `M.resources:list(namespace, kind, opts?)` → `{items}` — List resources. `opts`: `{label_selector, field_selector, limit}` - `M.resources:create(namespace, kind, body, opts?)` → resource — Create resource - `M.resources:update(namespace, kind, name, body, opts?)` → resource — Replace resource - `M.resources:patch(namespace, kind, name, body, opts?)` → resource — Patch resource - `M.resources:delete(namespace, kind, name, opts?)` → nil — Delete resource - `M.resources:exists(namespace, kind, name, opts?)` → bool — Check if resource exists - `M.resources:is_ready(namespace, kind, name, opts?)` → bool — Check if resource is ready (deployment, statefulset, daemonset, job, node) - `M.resources:wait_ready(namespace, kind, name, timeout_secs?, opts?)` → true — Wait for readiness, errors on timeout. Default 60s. ### Secrets (`M.secrets`) - `M.secrets:get(namespace, name, opts?)` → `{key=value}` — Get decoded secret data (base64-decoded) ### ConfigMaps (`M.configmaps`) - `M.configmaps:get(namespace, name, opts?)` → `{key=value}` — Get ConfigMap data ### Pods (`M.pods`) - `M.pods:list(namespace, opts?)` → `{items}` — List pods in namespace - `M.pods:status(namespace, opts?)` → `{running, pending, succeeded, failed, unknown, total}` — Get pod status counts - `M.pods:logs(namespace, pod_name, opts?)` → string — Get pod logs. `opts`: `{tail, container, previous, since}` ### Services (`M.services`) - `M.services:endpoints(namespace, name, opts?)` → [ip] — Get service endpoint IP addresses ### Deployments (`M.deployments`) - `M.deployments:rollout_status(namespace, name, opts?)` → `{desired, updated, ready, available, unavailable, complete}` — Get deployment rollout status ### Nodes (`M.nodes`) - `M.nodes:status(opts?)` → `[{name, ready, roles, capacity, allocatable}]` — Get all node statuses ### Namespaces (`M.namespaces`) - `M.namespaces:exists(name, opts?)` → bool — Check if namespace exists ### Events (`M.events`) - `M.events:list(namespace, opts?)` → `{items}` — List events in namespace - `M.events:for_resource(namespace, kind, name, opts?)` → `{items}` — Get events for a specific resource ### Backward Compatibility All legacy flat functions (`M.get_resource`, `M.list`, `M.get_secret`, `M.pod_status`, etc.) remain available and delegate to the sub-objects above. Example: ```lua local k8s = require("assay.k8s") -- New sub-object style k8s.resources:wait_ready("default", "deployment", "my-app", 120) local secret = k8s.secrets:get("default", "my-secret") log.info("DB password: " .. secret["password"]) -- Legacy style still works k8s.wait_ready("default", "deployment", "my-app", 120) local secret = k8s.get_secret("default", "my-secret") ``` ## assay.kargo Kargo continuous promotion. Stages, freight, promotions, warehouses, pipeline status. Client: `kargo.client(url, token)`. ### Stages - `c.stages:list(namespace)` -> [stage] -- List stages in namespace - `c.stages:get(namespace, name)` -> stage -- Get stage by name - `c.stages:status(namespace, name)` -> `{phase, current_freight_id, health, conditions}` -- Get stage status - `c.stages:is_healthy(namespace, name)` -> bool -- Check if stage is healthy (phase "Steady" or condition "Healthy") - `c.stages:wait_healthy(namespace, name, timeout_secs?)` -> true -- Wait for stage health. Default 60s. - `c.stages:pipeline_status(namespace)` -> `[{name, phase, freight, healthy}]` -- Get pipeline overview of all stages ### Freight - `c.freight:list(namespace, opts?)` -> [freight] -- List freight. `opts`: `{stage, warehouse}` for label filters - `c.freight:get(namespace, name)` -> freight -- Get freight by name - `c.freight:status(namespace, name)` -> status -- Get freight status ### Promotions - `c.promotions:list(namespace, opts?)` -> [promotion] -- List promotions. `opts`: `{stage}` filter - `c.promotions:get(namespace, name)` -> promotion -- Get promotion by name - `c.promotions:status(namespace, name)` -> `{phase, message, freight_id}` -- Get promotion status - `c.promotions:create(namespace, stage, freight)` -> promotion -- Create a promotion to promote freight to stage ### Warehouses - `c.warehouses:list(namespace)` -> [warehouse] -- List warehouses - `c.warehouses:get(namespace, name)` -> warehouse -- Get warehouse by name ### Projects - `c.projects:list()` -> [project] -- List Kargo projects - `c.projects:get(name)` -> project -- Get project by name Example: ```lua local kargo = require("assay.kargo") local c = kargo.client("https://kargo.example.com", env.get("KARGO_TOKEN")) c.promotions:create("my-project", "staging", "freight-abc123") c.stages:wait_healthy("my-project", "staging", 300) ``` ## assay.loki Loki log aggregation. Push logs, query with LogQL, labels, series, tail. Client: `loki.client(url)`. Module helper: `M.selector(labels)`. - `M.selector(labels)` → string — Build LogQL stream selector from labels table. `M.selector({app="nginx"})` → `{app="nginx"}` - `c.logs:push(stream_labels, entries)` → true — Push log entries. `entries`: array of strings or `{timestamp, line}` pairs - `c.queries:instant(logql, opts?)` → [result] — Instant LogQL query. `opts`: `{limit, time, direction}` - `c.queries:range(logql, opts?)` → [result] — Range LogQL query. `opts`: `{start, end_time, limit, step, direction}` - `c.queries:tail(logql, opts?)` → data — Tail log stream. `opts`: `{limit, start}` - `c.labels:list(opts?)` → [string] — List label names. `opts`: `{start, end_time}` - `c.labels:values(label_name, opts?)` → [string] — List values for a label. `opts`: `{start, end_time}` - `c.series:list(match_selectors, opts?)` → [series] — Query series metadata. `opts`: `{start, end_time}` - `c.health:ready()` → bool — Check Loki readiness - `c.health:metrics()` → string — Get Loki metrics in Prometheus exposition format Example: ```lua local loki = require("assay.loki") local c = loki.client("http://loki:3100") c.logs:push({app="myservice", env="prod"}, {"Request processed", "Job complete"}) local logs = c.queries:instant('{app="myservice"}', {limit = 10}) ``` ## assay.openbao OpenBao secrets management (Vault API-compatible). Alias for `assay.vault`. `require("assay.openbao")` returns the same module as `require("assay.vault")`. All methods are identical — see assay.vault documentation above. ## assay.ory.kratos Ory Kratos identity management. Self-service login, registration, recovery and settings flows, identity CRUD via the admin API, session introspection (whoami), and identity schemas. Client: `kratos.client({public_url="...", admin_url="..."})`. **Sessions** (`c.sessions`): - `c.sessions:whoami(cookie_or_token)` → session|nil — Introspect a session (nil on 401) - `c.sessions:list(identity_id)` → [session] — Admin: list sessions for an identity - `c.sessions:revoke(identity_id)` → nil — Admin: revoke all sessions for an identity **Flows** (`c.flows`): - `c.flows:create_login(opts?)` → flow — Initialize a login flow (browser or api) - `c.flows:get_login(flow_id, cookie?)` → flow — Fetch an existing login flow - `c.flows:submit_login(flow_id, payload, cookie?)` → `{session, session_token?}` — Submit login flow - `c.flows:create_registration(opts?)` → flow — Initialize a registration flow - `c.flows:get_registration(flow_id, cookie?)` → flow — Fetch a registration flow - `c.flows:submit_registration(flow_id, payload, cookie?)` → `{identity, session?}` — Submit registration flow - `c.flows:create_recovery(opts?)` → flow — Initialize a recovery flow - `c.flows:get_recovery(flow_id, cookie?)` → flow — Fetch a recovery flow - `c.flows:submit_recovery(flow_id, payload, cookie?)` → flow — Submit recovery flow - `c.flows:create_settings(cookie)` → flow — Initialize a settings flow - `c.flows:get_settings(flow_id, cookie?)` → flow — Fetch a settings flow - `c.flows:submit_settings(flow_id, payload, cookie?)` → flow — Submit settings flow **Identities** (`c.identities`): - `c.identities:list(opts?)` → [identity] — Admin: list identities - `c.identities:get(id)` → identity|nil — Admin: get identity by ID - `c.identities:create(payload)` → identity — Admin: create identity - `c.identities:update(id, payload)` → identity — Admin: update identity - `c.identities:delete(id)` → nil — Admin: delete identity **Schemas** (`c.schemas`): - `c.schemas:list()` → [schema] — List identity schemas - `c.schemas:get(id)` → schema|nil — Get schema by ID Example: ```lua local kratos = require("assay.ory.kratos") local c = kratos.client({ public_url = "http://kratos-public:4433", admin_url = "http://kratos-admin:4434", }) local session = c.sessions:whoami(cookie) log.info("Logged in as: " .. session.identity.traits.email) ``` ## assay.ory.hydra Ory Hydra OAuth2 and OpenID Connect server. OAuth2 client CRUD via the admin API, authorize URL builder, token exchange, accept/reject login and consent challenges, introspection, JWK endpoint, and OIDC discovery. Client: `hydra.client({public_url="...", admin_url="..."})`. **Clients** (`c.clients`): - `c.clients:list(opts?)` → [client] — Admin: list OAuth2 clients - `c.clients:get(id)` → client|nil — Admin: get client by ID - `c.clients:create(payload)` → client — Admin: create OAuth2 client - `c.clients:update(id, payload)` → client — Admin: update OAuth2 client - `c.clients:delete(id)` → nil — Admin: delete OAuth2 client **OAuth2** (`c.oauth2`): - `c.oauth2:authorize_url(client_id, opts)` → string — Build an authorization URL - `c.oauth2:exchange_code(opts)` → `{access_token, id_token?, refresh_token?, expires_in}` — Exchange code for tokens - `c.oauth2:refresh_token(client_id, client_secret, refresh_token)` → tokens — Refresh an access token - `c.oauth2:introspect(token)` → `{active, sub, scope, ...}` — Admin introspection - `c.oauth2:revoke_token(client_id, client_secret, token)` → nil — Revoke a token **Login challenges** (`c.login`): - `c.login:get(challenge)` → `{challenge, subject, client, ...}` — Fetch a pending login challenge - `c.login:accept(challenge, subject, opts?)` → `{redirect_to}` — Accept a login challenge - `c.login:reject(challenge, error?)` → `{redirect_to}` — Reject a login challenge **Consent challenges** (`c.consent`): - `c.consent:get(challenge)` → `{challenge, subject, requested_scope, ...}` — Fetch a pending consent challenge - `c.consent:accept(challenge, opts)` → `{redirect_to}` — Accept a consent challenge - `c.consent:reject(challenge, error?)` → `{redirect_to}` — Reject a consent challenge **Logout challenges** (`c.logout`): - `c.logout:get(challenge)` → `{request_url, rp_initiated, sid, subject, client}` — Fetch a pending logout challenge - `c.logout:accept(challenge)` → `{redirect_to}` — Accept a logout challenge - `c.logout:reject(challenge)` → nil — Reject a logout challenge **Discovery** (`c.discovery`): - `c.discovery:openid_config()` → `{issuer, authorization_endpoint, ...}` — OIDC discovery document - `c.discovery:jwks()` → `{keys}` — JSON Web Key Set Example: ```lua local hydra = require("assay.ory.hydra") local c = hydra.client({ public_url = "https://hydra.example.com", admin_url = "http://hydra-admin:4445", }) local client = c.clients:create({ client_name = "my-app", grant_types = { "authorization_code", "refresh_token" }, redirect_uris = { "https://app.example.com/callback" }, }) ``` ## assay.ory.keto Ory Keto relationship-based access control (Zanzibar-style ReBAC). Relation-tuple CRUD, permission checks, role/group membership queries, and the expand API. Client: `keto.client(read_url, {write_url="..."})`. **Tuples** (`c.tuples`): - `c.tuples:list(query)` → `{relation_tuples, next_page_token}` — List tuples matching query filters - `c.tuples:create(tuple)` → nil — Create a relation tuple `{namespace, object, relation, subject_id|subject_set}` - `c.tuples:delete(tuple)` → nil — Delete a relation tuple - `c.tuples:delete_all(filters)` → nil — Delete all matching relation tuples **Permissions** (`c.permissions`): - `c.permissions:check(namespace, object, relation, subject)` → bool — Check if a relation tuple allows access - `c.permissions:check({namespace, object, relation, subject_id})` → bool — Check (table form) - `c.permissions:batch_check(tuples)` → [bool] — Check multiple tuples in one call - `c.permissions:expand(namespace, object, relation, depth?)` → tree — Expand a subject tree (Zanzibar expand) **Roles** (`c.roles`): - `c.roles:user_roles(user_id, namespace?)` → [{object, relation}] — Get all role memberships for a user - `c.roles:has_any(user_id, role_objects, namespace?)` → bool — Check if a user has any of the given roles Example: ```lua local keto = require("assay.ory.keto") local c = keto.client("http://keto-read:4466", { write_url = "http://keto-write:4467", }) c.tuples:create({ namespace = "apps", object = "cc", relation = "admin", subject_id = "user:alice", }) assert(c.permissions:check("apps", "cc", "admin", "user:alice")) ``` ## assay.ory.rbac Capability-based RBAC engine layered on top of Ory Keto. Define a policy once (role → capability set) and get user lookups, capability checks, and membership management for free. Users can hold multiple roles and the effective capability set is the union, so separation of duties is enforceable at the authorization layer (an `approver` role can have `approve` without also getting `trigger`, even if listed above an `operator` role with `trigger`). Policy: `rbac.policy({namespace, keto, roles, default_role?})`. `namespace` filters Keto tuples (e.g. `"command-center"`); `keto` is a Keto client; `roles` maps role names to `{rank, capabilities, label?, description?}`; `default_role` is the role assumed for users with no memberships. **Users** (`p.users`): - `p.users:roles(user_id)` → `{role}` — held roles, sorted by rank descending - `p.users:primary_role(user_id)` → role — highest-ranked, for compact UI badges - `p.users:capabilities(user_id)` → `{cap=true,...}` — union over all held roles, falls back to `default_role` caps when empty - `p.users:has_capability(user_id, cap)` → bool — single capability check **Members** (`p.members`): - `p.members:add(user_id, role)` — idempotent membership add (no-op if already a member) - `p.members:remove(user_id, role)` — membership remove (swallows 404) - `p.members:list(role)` → `{user_id}` — direct members of a role - `p.members:list_all()` → `{[role]={user_id,...}}` — full snapshot - `p.members:reset(role)` — delete all members of a role (for bootstrap/seed scripts) **Policy** (`p.policy`): - `p.policy:roles()` → `[role_name]` — all configured role names, highest rank first - `p.policy:get(role_name)` → `{rank, capabilities}` — role metadata from the policy definition **Middleware** (`p.middleware`): - `p.middleware:require_capability(cap, handler)` → handler — `http.serve` middleware that 403s callers without `cap` Example: ```lua local keto = require("assay.ory.keto") local rbac = require("assay.ory.rbac") local kc = keto.client("http://keto-read:4466", { write_url = "http://keto-write:4467" }) local policy = rbac.policy({ namespace = "command-center", keto = kc, default_role = "viewer", roles = { owner = { rank = 5, capabilities = { "manage_roles", "approve", "trigger", "view" } }, admin = { rank = 4, capabilities = { "manage_roles", "approve", "trigger", "view" } }, approver = { rank = 3, capabilities = { "approve", "view" } }, operator = { rank = 2, capabilities = { "trigger", "view" } }, viewer = { rank = 1, capabilities = { "view" } }, }, }) policy.members:add("user:alice", "approver") assert(policy.users:has_capability("user:alice", "approve")) assert(not policy.users:has_capability("user:alice", "trigger")) ``` ## assay.ory Convenience wrapper re-exporting `assay.ory.kratos`, `assay.ory.hydra`, `assay.ory.keto`, and `assay.ory.rbac`, with `ory.connect(opts)` to build all three Ory clients in a single call. - `M.kratos` — re-export of `assay.ory.kratos` - `M.hydra` — re-export of `assay.ory.hydra` - `M.keto` — re-export of `assay.ory.keto` - `M.rbac` — re-export of `assay.ory.rbac` - `M.connect(opts)` → `{kratos, hydra, keto}` — Build all three clients. `opts`: `{kratos_public, kratos_admin, hydra_public, hydra_admin, keto_read, keto_write}` Example: ```lua local ory = require("assay.ory") local o = ory.connect({ kratos_public = "http://kratos-public:4433", kratos_admin = "http://kratos-admin:4434", hydra_public = "https://hydra.example.com", hydra_admin = "http://hydra-admin:4445", keto_read = "http://keto-read:4466", keto_write = "http://keto-write:4467", }) local allowed = o.keto.permissions:check("apps", "cc", "admin", "user:alice") ``` ## assay.postgres PostgreSQL database helpers. User/database management, grants, Vault integration. Client: `postgres.client(host, port, username, password, database?)`. Database defaults to `"postgres"`. Module helper: `M.client_from_vault(vault_client, vault_path, host, port?)`. ### Queries - `c.queries:query(sql, params?)` -> [row] -- Execute SQL query, return rows - `c.queries:execute(sql, params?)` -> number -- Execute SQL statement, return affected count ### Connection - `c:close()` -> nil -- Close database connection ### Users - `c.users:exists(username)` -> bool -- Check if PostgreSQL role exists - `c.users:ensure(username, password, opts?)` -> bool -- Create user if not exists. `opts`: `{createdb, superuser}`. Returns true if created. ### Databases - `c.databases:exists(dbname)` -> bool -- Check if database exists - `c.databases:ensure(dbname, owner?)` -> bool -- Create database if not exists. Returns true if created. - `c.databases:grant(database_name, username, privileges?)` -> nil -- Grant privileges. Default: `"ALL PRIVILEGES"`. ### Module Helpers - `M.client_from_vault(vault_client, vault_path, host, port?)` -> client -- Create client using credentials from Vault KV. Port defaults to 5432. Example: ```lua local postgres = require("assay.postgres") local vault = require("assay.vault") local vc = vault.authenticated_client("http://vault:8200") local pg = postgres.client_from_vault(vc, "myapp/postgres", "postgres.default.svc", 5432) pg.users:ensure("myapp", crypto.random(16), {createdb = true}) pg.databases:ensure("myapp_db", "myapp") pg.databases:grant("myapp_db", "myapp") pg:close() ``` ## assay.prometheus Prometheus monitoring queries. PromQL instant/range queries, alerts, targets, rules, series. Client: `prometheus.client(url)`. - `c.queries:instant(promql)` → number|[{metric, value}] — Instant PromQL query. Single result returns number, multiple returns array. - `c.queries:range(promql, start_time, end_time, step)` → [result] — Range PromQL query over time window - `c.alerts:list()` → [alert] — List active alerts - `c.targets:list()` → `{activeTargets, droppedTargets}` — List scrape targets with health status - `c.targets:metadata(opts?)` → [metadata] — Get targets metadata. `opts`: `{match_target, metric, limit}` - `c.rules:list(opts?)` → [group] — List alerting/recording rules. `opts.type` filters by `"alert"` or `"record"`. - `c.labels:values(label_name)` → [string] — List all values for a label name - `c.series:list(match_selectors)` → [series] — Query series metadata. `match_selectors` is array of selectors. - `c.config:reload()` → bool — Trigger Prometheus configuration reload via `/-/reload` Example: ```lua local prom = require("assay.prometheus") local c = prom.client("http://prometheus:9090") local count = c.queries:instant("count(up)") assert.gt(count, 0, "No targets up") ``` ## regex Regular expressions (Rust regex syntax). No `require()` needed. - `regex.match(pattern, str)` → bool — Test if pattern matches string - `regex.find(pattern, str)` → string|nil — Find first match - `regex.find_all(pattern, str)` → [string] — Find all matches - `regex.replace(pattern, str, replacement)` → string — Replace all matches ## assay.s3 S3-compatible object storage with AWS Signature V4 authentication. Client: `s3.client({endpoint="...", region="...", access_key="...", secret_key="...", path_style=true})`. Works with AWS S3, Cloudflare R2, iDrive e2, MinIO, and any S3-compatible provider. ### Buckets - `c.buckets:create(bucket)` -> true -- Create a new bucket - `c.buckets:delete(bucket)` -> true -- Delete a bucket - `c.buckets:list()` -> `[{name, creation_date}]` -- List all buckets - `c.buckets:exists(bucket)` -> bool -- Check if bucket exists ### Objects - `c.objects:put(bucket, key, body, opts?)` -> true -- Upload object. `opts`: `{content_type}` - `c.objects:get(bucket, key)` -> string|nil -- Download object content (nil if 404) - `c.objects:delete(bucket, key)` -> true -- Delete an object - `c.objects:list(bucket, opts?)` -> `{objects, is_truncated, next_continuation_token, key_count}` -- List objects. `opts`: `{prefix, max_keys, continuation_token}`. Each object: `{key, size, last_modified}` - `c.objects:head(bucket, key)` -> `{status, headers}`|nil -- Get object metadata (nil if 404) - `c.objects:copy(src_bucket, src_key, dst_bucket, dst_key)` -> true -- Copy object between buckets Example: ```lua local s3 = require("assay.s3") local c = s3.client({ endpoint = "https://s3.us-east-1.amazonaws.com", region = "us-east-1", access_key = env.get("AWS_ACCESS_KEY_ID"), secret_key = env.get("AWS_SECRET_ACCESS_KEY"), }) c.objects:put("my-bucket", "data/report.json", json.encode({status = "complete"})) local content = c.objects:get("my-bucket", "data/report.json") ``` ## json JSON serialization. No `require()` needed. - `json.parse(str)` → table — Parse JSON string to Lua table - `json.encode(table)` → string — Encode Lua table to JSON string ## yaml YAML serialization. No `require()` needed. - `yaml.parse(str)` → table — Parse YAML string to Lua table - `yaml.encode(table)` → string — Encode Lua table to YAML string ## toml TOML serialization. No `require()` needed. - `toml.parse(str)` → table — Parse TOML string to Lua table - `toml.encode(table)` → string — Encode Lua table to TOML string ## template Jinja2-compatible template rendering. No `require()` needed. - `template.render(path, vars)` → string — Render template file with variables - `template.render_string(tmpl, vars)` → string — Render template string with variables - Supports: `{{ var }}`, `{% for %}`, `{% if %}`, `{% include %}`, filters ## assay.traefik Traefik reverse proxy API. Routers, services, middlewares, entrypoints, TLS status. Client: `traefik.client(url)`. - `c.info:overview()` → overview — Get Traefik dashboard overview - `c.info:version()` → version — Get Traefik version - `c.info:rawdata()` → data — Get raw Traefik configuration data - `c.entrypoints:list()` → [entrypoint] — List all entrypoints - `c.entrypoints:get(name)` → entrypoint — Get entrypoint by name - `c.routers:list()` → [router] — List HTTP routers - `c.routers:get(name)` → router — Get HTTP router by name - `c.routers:is_enabled(name)` → bool — Check if router status is "enabled" - `c.routers:has_tls(name)` → bool — Check if router has TLS configured - `c.routers:healthy()` → enabled, errored — Count enabled vs errored HTTP routers (two return values) - `c.services:list()` → [service] — List HTTP services - `c.services:get(name)` → service — Get HTTP service by name - `c.services:server_count(name)` → number — Count load balancer servers for service - `c.middlewares:list()` → [middleware] — List HTTP middlewares - `c.middlewares:get(name)` → middleware — Get HTTP middleware by name - `c.tcp:routers()` → [router] — List TCP routers - `c.tcp:services()` → [service] — List TCP services Example: ```lua local traefik = require("assay.traefik") local c = traefik.client("http://traefik:8080") local enabled, errored = c.routers:healthy() assert.eq(errored, 0, "Some routers have errors") ``` ## assay.unleash Unleash feature flag management. Projects, features, environments, strategies, API tokens. Client: `unleash.client(url, {token="..."})`. Module helpers: `M.wait()`, `M.ensure_project()`, `M.ensure_environment()`, `M.ensure_token()`. ### Health - `c:health()` → `{health}` — Check Unleash health ### Projects - `c:projects()` → [project] — List projects - `c:project(id)` → project|nil — Get project by ID - `c:create_project(project)` → project — Create project. `project`: `{id, name, description?}` - `c:update_project(id, project)` → project — Update project - `c:delete_project(id)` → nil — Delete project ### Environments - `c:environments()` → [environment] — List all environments - `c:enable_environment(project_id, env_name)` → nil — Enable environment on project - `c:disable_environment(project_id, env_name)` → nil — Disable environment on project ### Features - `c:features(project_id)` → [feature] — List features in project - `c:feature(project_id, name)` → feature|nil — Get feature by name - `c:create_feature(project_id, feature)` → feature — Create feature. `feature`: `{name, type?, description?}` - `c:update_feature(project_id, name, feature)` → feature — Update feature - `c:archive_feature(project_id, name)` → nil — Archive (soft-delete) a feature - `c:toggle_on(project_id, name, env)` → nil — Enable feature in environment - `c:toggle_off(project_id, name, env)` → nil — Disable feature in environment ### Strategies - `c:strategies(project_id, feature_name, env)` → [strategy] — List strategies for feature in environment - `c:add_strategy(project_id, feature_name, env, strategy)` → strategy — Add strategy. `strategy`: `{name, parameters?}` ### API Tokens - `c:tokens()` → [token] — List API tokens - `c:create_token(token_config)` → token — Create token. `token_config`: `{username, type, environment?, projects?}` - `c:delete_token(secret)` → nil — Delete API token by secret ### Module Helpers - `M.wait(url, opts?)` → true — Wait for Unleash healthy. `opts`: `{timeout, interval}`. Default 60s. - `M.ensure_project(client, project_id, opts?)` → project — Ensure project exists. `opts`: `{name, description}` - `M.ensure_environment(client, project_id, env_name)` → true — Ensure environment enabled on project - `M.ensure_token(client, opts)` → token — Ensure API token exists. `opts`: `{username, type, environment?, projects?}` Example: ```lua local unleash = require("assay.unleash") unleash.wait("http://unleash:4242") local c = unleash.client("http://unleash:4242", {token = env.get("UNLEASH_ADMIN_TOKEN")}) unleash.ensure_project(c, "my-project", {name = "My Project"}) unleash.ensure_environment(c, "my-project", "production") c:create_feature("my-project", {name = "dark-mode", type = "release"}) c:toggle_on("my-project", "dark-mode", "production") ``` ## log Structured logging. No `require()` needed. - `log.info(msg)` — Log info message - `log.warn(msg)` — Log warning message - `log.error(msg)` — Log error message ## env Environment variable access. No `require()` needed. - `env.get(key)` → string|nil — Get environment variable value ## sleep Sleep utility. No `require()` needed. - `sleep(secs)` → nil — Sleep for N seconds (supports fractional: `sleep(0.5)`) ## time Timestamp utility. No `require()` needed. - `time()` → number — Unix timestamp in seconds (with fractional milliseconds) ## assay.vault HashiCorp Vault secrets management. KV v2, policies, auth methods, transit encryption, PKI certificates, tokens. Client: `vault.client(url, token)`. Module helpers: `M.wait()`, `M.authenticated_client()`, `M.ensure_credentials()`, `M.assert_secret()`. ### Raw API - `c:read(path)` -> data|nil -- Read secret at path (raw Vault API path without `/v1/`) - `c:write(path, payload)` -> data|nil -- Write secret to path - `c:delete(path)` -> nil -- Delete secret at path - `c:list(path)` -> [string] -- List keys at path ### KV v2 Secrets - `c.kv:get(mount, key)` -> `{data}`|nil -- Read KV v2 secret. `mount` = engine mount (e.g. `"secrets"`) - `c.kv:put(mount, key, data)` -> result -- Write KV v2 secret. `data` is a table. - `c.kv:delete(mount, key)` -> nil -- Delete KV v2 secret - `c.kv:list(mount, prefix?)` -> [string] -- List KV v2 keys under prefix - `c.kv:metadata(mount, key)` -> metadata|nil -- Get KV v2 secret metadata ### System / Health - `c.sys:health()` -> `{initialized, sealed, version, ...}` -- Get Vault health (works even when sealed) - `c.sys:seal_status()` -> `{sealed, initialized, ...}` -- Get seal status - `c.sys:is_sealed()` -> bool -- Check if Vault is sealed - `c.sys:is_initialized()` -> bool -- Check if Vault is initialized ### ACL Policies - `c.policies:get(name)` -> policy|nil -- Get ACL policy - `c.policies:create(name, rules)` -> nil -- Create or update ACL policy - `c.policies:delete(name)` -> nil -- Delete ACL policy - `c.policies:list()` -> [string] -- List ACL policies ### Auth Methods - `c.auth:enable(path, type, opts?)` -> nil -- Enable auth method. `opts`: `{description, config}` - `c.auth:disable(path)` -> nil -- Disable auth method - `c.auth:methods()` -> `{path: config}` -- List enabled auth methods - `c.auth:config(path, config)` -> nil -- Configure auth method - `c.auth:create_role(path, role_name, config)` -> nil -- Create auth role - `c.auth:get_role(path, role_name)` -> role|nil -- Read auth role - `c.auth:list_roles(path)` -> [string] -- List auth roles ### Secrets Engines - `c.engines:enable(path, type, opts?)` -> nil -- Enable secrets engine. `opts`: `{description, config, options}` - `c.engines:disable(path)` -> nil -- Disable secrets engine - `c.engines:list()` -> `{path: config}` -- List enabled secrets engines - `c.engines:tune(path, config)` -> nil -- Tune secrets engine configuration ### Token Management - `c.token:create(opts?)` -> `{client_token, ...}` -- Create new token. `opts`: `{policies, ttl, ...}` - `c.token:lookup(token)` -> token_info|nil -- Lookup token details - `c.token:lookup_self()` -> token_info|nil -- Lookup current token - `c.token:revoke(token)` -> nil -- Revoke a token - `c.token:revoke_self()` -> nil -- Revoke current token ### Transit Encryption - `c.transit:encrypt(key_name, plaintext)` -> ciphertext|nil -- Encrypt with transit engine (auto base64 encodes) - `c.transit:decrypt(key_name, ciphertext)` -> plaintext|nil -- Decrypt with transit engine (auto base64 decodes) - `c.transit:create_key(key_name, opts?)` -> nil -- Create transit encryption key - `c.transit:list_keys()` -> [string] -- List transit keys ### PKI Certificates - `c.pki:issue(mount, role_name, opts?)` -> cert|nil -- Issue certificate. `opts`: `{common_name, ttl, ...}` - `c.pki:ca_cert(mount?)` -> string -- Get CA certificate PEM. `mount` defaults to `"pki"`. - `c.pki:create_role(mount, role_name, opts?)` -> nil -- Create PKI role ### Module Helpers - `M.wait(url, opts?)` -> true -- Wait for Vault to become healthy. `opts`: `{timeout, interval, health_path}` - `M.authenticated_client(url, opts?)` -> client -- Create client using K8s secret for token. `opts`: `{secret_namespace, secret_name, secret_key, timeout}` - `M.ensure_credentials(client, path, check_key, generator)` -> creds -- Check if creds exist at KV path, generate if missing - `M.assert_secret(client, path, expected_keys)` -> data -- Assert secret exists with all expected keys Example: ```lua local vault = require("assay.vault") local c = vault.authenticated_client("http://vault:8200") c.kv:put("secrets", "myapp/db", {username = "admin", password = crypto.random(32)}) local creds = c.kv:get("secrets", "myapp/db") ``` ## assay.velero Velero backup and restore. Backups, restores, schedules, storage locations. Client: `velero.client(url, token, namespace?)`. Default namespace: `"velero"`. ### Backups - `c.backups:list()` -> [backup] -- List all backups - `c.backups:get(name)` -> backup|nil -- Get backup by name - `c.backups:status(name)` -> `{phase, started, completed, expiration, errors, warnings, items_backed_up, items_total}` -- Get status - `c.backups:is_completed(name)` -> bool -- Check if backup phase is "Completed" - `c.backups:is_failed(name)` -> bool -- Check if backup phase is "Failed" or "PartiallyFailed" - `c.backups:latest(schedule_name)` -> backup|nil -- Get most recent backup for a schedule ### Restores - `c.restores:list()` -> [restore] -- List all restores - `c.restores:get(name)` -> restore|nil -- Get restore by name - `c.restores:status(name)` -> `{phase, started, completed, errors, warnings}` -- Get restore status - `c.restores:is_completed(name)` -> bool -- Check if restore phase is "Completed" ### Schedules - `c.schedules:list()` -> [schedule] -- List all schedules - `c.schedules:get(name)` -> schedule|nil -- Get schedule by name - `c.schedules:status(name)` -> `{phase, last_backup, validation_errors}` -- Get schedule status - `c.schedules:is_enabled(name)` -> bool -- Check if schedule phase is "Enabled" - `c.schedules:all_enabled()` -> `{enabled, disabled, total, disabled_names}` -- Check all schedules ### Storage Locations - `c.storage_locations:list()` -> [bsl] -- List backup storage locations - `c.storage_locations:get(name)` -> bsl|nil -- Get backup storage location - `c.storage_locations:is_available(name)` -> bool -- Check if storage location phase is "Available" - `c.storage_locations:all_available()` -> `{available, unavailable, total, unavailable_names}` -- Check all storage locations ### Volume Snapshots - `c.volume_snapshots:list()` -> [vsl] -- List volume snapshot locations - `c.volume_snapshots:get(name)` -> vsl|nil -- Get volume snapshot location ### Backup Repositories - `c.repositories:list()` -> [repo] -- List backup repositories - `c.repositories:get(name)` -> repo|nil -- Get backup repository Example: ```lua local velero = require("assay.velero") local c = velero.client("https://k8s-api:6443", env.get("K8S_TOKEN"), "velero") local latest = c.backups:latest("daily-backup") if latest then assert.eq(c.backups:is_completed(latest.metadata.name), true) end ``` ## workflow Durable workflow engine + Lua client. The engine runs in `assay serve`; any assay Lua app becomes a worker via `require("assay.workflow")`. Workflow code runs deterministically and replays from a persisted event log, so worker crashes don't lose work and side effects don't duplicate. Three pieces, one binary: - **Engine** — `assay serve` starts a long-lived server (REST + SSE + dashboard). - **CLI** — `assay workflow` and `assay schedule` manage workflows from the shell. - **Client** — `require("assay.workflow")` lets Lua apps register activities + workflow handlers and become workers. The engine and clients communicate over HTTP — any language with an HTTP client can implement a worker, not just Lua. ### Engine — `assay serve` Start the workflow server. - `assay serve` — start with default SQLite backend, port 8080, no auth - `assay serve --port 8085` — listen on a different port - `assay serve --backend sqlite:///var/lib/assay/workflows.db` — explicit SQLite path - `assay serve --backend postgres://user:pass@host:5432/assay` — Postgres for multi-instance - `DATABASE_URL=postgres://... assay serve` — read backend URL from env (avoids putting credentials in argv, where they'd show up in `ps`) Authentication modes (mutually exclusive — pick one): - `--no-auth` (default) — open access. Use only behind a trusted gateway. - `--auth-api-key` — clients send `Authorization: Bearer `. Manage keys with `--generate-api-key` and `--list-api-keys`. Keys are SHA256-hashed at rest. - `--auth-issuer https://idp.example.com --auth-audience assay` — JWT/OIDC. The engine fetches and caches the issuer's JWKS to validate signatures; works with any standard OIDC provider (Auth0, Okta, Dex, Keycloak, Cloudflare Access, …). SQLite is single-instance only — the engine takes an `engine_lock` row and refuses to start if another instance holds it. For multi-instance deployment (Kubernetes, Docker Swarm), use Postgres; the cron scheduler picks a leader via `pg_advisory_lock` so only one engine fires. The engine serves: - `GET /api/v1/health` — liveness probe - `GET /api/v1/openapi.json` — OpenAPI 3 spec for all endpoints - `GET /api/v1/docs` — interactive API docs (Scalar) - `GET /workflow/` — built-in dashboard (workflows, schedules, workers, queues, namespaces, settings; live updates over SSE; light + dark theme) - `GET /api/v1/events/stream?namespace=X` — SSE event stream - 23+ REST endpoints for workflow lifecycle, worker registration, task polling, schedules, namespaces, workflow-task dispatch — see the OpenAPI spec for the full list `ASSAY_WF_DISPATCH_TIMEOUT_SECS` env var (default `30`) controls how long a worker can be silent before its dispatch lease is forcibly released — see "crash safety" below. ### CLI — `assay workflow` / `assay schedule` Talk to a running engine. Reads `ASSAY_ENGINE_URL` (default `http://localhost:8080`). - `assay workflow list [--status RUNNING] [--type IngestData]` - `assay workflow describe ` — full state, history, children - `assay workflow signal [payload]` - `assay workflow cancel ` — graceful cancel (workflow gets a chance to clean up) - `assay workflow terminate [--reason "…"]` — hard stop - `assay schedule list` - `assay schedule create --type IngestData --cron "0 * * * * *"` — 6-field cron (with seconds) - `assay schedule pause ` / `resume ` / `delete ` ### Lua client — `require("assay.workflow")` Register the assay process as a worker that runs **both** workflow handlers (orchestration) and activity handlers (concrete work) for a queue. - `workflow.connect(url, opts?)` → nil — Connect and verify the engine is reachable - `url`: engine URL (e.g. `"http://localhost:8080"`) - `opts`: `{ token = "Bearer abc..." }` for auth (api-key or JWT) - `workflow.define(name, handler)` → nil — Register a workflow type. Handler runs as a coroutine; uses `ctx:` methods to drive activities, timers, signals, child workflows. See "Workflow handler context" below. - `workflow.activity(name, handler)` → nil — Register an activity implementation. Activities run once and their result is persisted; failures retry per the activity's policy. - `workflow.listen(opts)` → blocks — Polls workflow tasks AND activity tasks on the queue. - `opts.queue` (default `"default"`) — task queue - `opts.identity` — friendly worker name (default `"assay-worker-"`) - `opts.max_concurrent_workflows` (default 10), `opts.max_concurrent_activities` (default 20) Client-side inspection / control (no `listen` required): - `workflow.start(opts)` → `{ workflow_id, run_id, status }` — Start a workflow - `opts`: `{ workflow_type, workflow_id, input?, task_queue? }` - `workflow.signal(workflow_id, signal_name, payload)` — Send a signal - `workflow.describe(workflow_id)` → table — Get current state + result - `workflow.cancel(workflow_id)` — Cancel a running workflow ### Workflow handler context (`ctx`) Inside `workflow.define(name, function(ctx, input) ... end)`: - `ctx:execute_activity(name, input, opts?)` → result — Schedule an activity, block until complete, return result. Raises if the activity fails after retries. `opts`: `{ task_queue?, max_attempts?, initial_interval_secs?, backoff_coefficient?, start_to_close_secs?, heartbeat_timeout_secs? }`. - `ctx:sleep(seconds)` → nil — Durable timer. Survives worker bouncing; another worker resumes the workflow when the timer fires. - `ctx:wait_for_signal(name)` → payload — Block until a matching signal arrives. Returns the signal's JSON payload (or nil if signaled with no payload). Multiple waits for the same name consume signals in arrival order. - `ctx:start_child_workflow(workflow_type, opts)` → result — Start a child workflow and block until it completes; raises if it failed. `opts.workflow_id` is required and **must be deterministic** (same id every replay). - `ctx:side_effect(name, function() … end)` → value — Run a non-deterministic operation exactly once. The function runs in the worker, the value is recorded in the workflow event log, and on every subsequent replay the cached value is returned without re-running. Use for `crypto.uuid()`, `os.time()`, anything reading external mutable state. Inside `workflow.activity(name, function(ctx, input) ... end)`: - `ctx:heartbeat(details?)` — Tell the engine you're still alive. Required for activities with `heartbeat_timeout_secs` set; the engine reassigns the activity if heartbeats stop. ### Crash safety Workflow code is **deterministic by replay**. Each `ctx:` call gets a per-execution sequence number and the engine persists `ActivityScheduled/Completed/Failed`, `TimerScheduled/Fired`, `SignalReceived`, `SideEffectRecorded`, `ChildWorkflowStarted/Completed/Failed`, `WorkflowAwaitingSignal`, `WorkflowCancelRequested` events. When a worker is asked to run a workflow task it receives the full event history; `ctx:` calls short-circuit to cached values for everything that's already in history, so the workflow always reaches the same state and the only thing that re-runs is the next unfulfilled step. Specific crash modes: - **Activity worker dies mid-execution** — the activity's `last_heartbeat` ages out (per-activity `heartbeat_timeout_secs`); the engine re-queues per the retry policy. - **Workflow worker dies mid-replay** — the workflow's `dispatch_last_heartbeat` ages out (`ASSAY_WF_DISPATCH_TIMEOUT_SECS`, default 30s); any worker on the queue picks it up and replays from the event log. - **Engine dies** — all state is in the DB. On restart, in-flight workflow + activity tasks become claimable again as their heartbeats age out. `ctx:side_effect` is the escape hatch for any operation that would produce different values across replays (current time, random IDs, external HTTP). The result is recorded once on first execution and returned from cache thereafter, even after a worker crash. ### Example — sequential activities + signal ```lua local workflow = require("assay.workflow") workflow.connect("http://assay.example.com", { token = env.get("ASSAY_TOKEN") }) workflow.define("ApproveAndDeploy", function(ctx, input) local artifact = ctx:execute_activity("build", { ref = input.git_sha }) -- pause until a human signals "approve" via the API or dashboard local approval = ctx:wait_for_signal("approve") return ctx:execute_activity("deploy", { image = artifact.image, env = input.target_env, approver = approval.by, }) end) workflow.activity("build", function(ctx, input) local resp = http.post("https://ci/build", { ref = input.ref }) if resp.status ~= 200 then error("build failed: " .. resp.status) end return { image = json.parse(resp.body).image } end) workflow.activity("deploy", function(ctx, input) local resp = http.post("https://k8s/apply", input) if resp.status ~= 200 then error("deploy failed: " .. resp.status) end return { url = json.parse(resp.body).url, approver = input.approver } end) workflow.listen({ queue = "deploys" }) -- blocks ``` Start a run, signal approval, see the result: ```sh assay workflow start --type ApproveAndDeploy --id deploy-1234 \ --input '{"git_sha":"abc123","target_env":"staging"}' assay workflow signal deploy-1234 approve '{"by":"alice"}' assay workflow describe deploy-1234 # status: COMPLETED, result: {url, approver} ``` ### Concepts - **Activity** — a unit of work with at-least-once semantics. Result persisted before progress continues. Configurable retry policy, start-to-close timeout, heartbeat timeout. - **Workflow** — deterministic orchestration of activities, sleeps, signals, child workflows. Full event history is persisted; a crashed worker → another worker replays from history. - **Task queue** — a named queue workers subscribe to. Workflows are routed to a queue; only workers on that queue claim them. - **Namespace** — logical tenant. Workflows / schedules / workers in one namespace are invisible to others. Default `main`. Manage via the dashboard or `POST /api/v1/namespaces`. - **Signal** — async message delivered to a running workflow; consumed via `ctx:wait_for_signal`. - **Schedule** — cron expression that starts a workflow recurringly. The engine's scheduler uses leader election under Postgres so only one instance fires. - **Child workflow** — workflow started by another workflow. Cancellation propagates from parent to all children recursively. - **Side effect** — non-deterministic operation captured in history on first call so all replays see the same value. ### Dashboard `/workflow/` (or just `/` — redirects). Real-time monitoring, dark/light, brand-aligned with [assay.rs](https://assay.rs). Views: workflows (list with status filter, drill-in to event timeline + children), schedules, workers, queues, namespaces, settings. Live updates via SSE. Cache-busted asset URLs (per-process startup stamp) so a deploy is reflected immediately. ### Notes - The whole engine + dashboard + Lua client is gated behind the `workflow` cargo feature, which is **enabled by default**. To build assay without the engine: `cargo install assay-lua --no-default-features --features cli,db,server`. When disabled, `assay serve` prints an error instead of starting. - The cron crate used by the scheduler requires **6- or 7-field** cron expressions (with seconds). The common 5-field form fails to parse. Use `0 * * * * *` for "every minute on the zero second" or `* * * * * *` for "every second." - Parallel activities (Promise.all-style) are not yet supported. Use sequential `ctx:execute_activity` calls or kick off independent child workflows. Tracked as a follow-up. - The engine is also publishable as a standalone Rust crate (`assay-workflow`) for embedding in non-Lua Rust applications. ## ws WebSocket client. No `require()` needed. - `ws.connect(url)` → conn — Connect to WebSocket server - `ws.send(conn, msg)` → nil — Send message - `ws.recv(conn)` → string — Receive message (blocking) - `ws.close(conn)` → nil — Close connection ## assay.zitadel Zitadel OIDC identity management. Projects, OIDC apps, IdPs, users, login policies. Client: `zitadel.client({url="...", domain="...", machine_key=...})` or `{..., machine_key_file="..."}` or `{..., token="..."}`. Authenticates via JWT machine key exchange. ### Domains - `c.domains:ensure_primary(domain)` -> bool -- Set organization primary domain ### Projects - `c.projects:find(name)` -> project|nil -- Find project by exact name - `c.projects:create(name, opts?)` -> project -- Create project. `opts`: `{projectRoleAssertion}` - `c.projects:ensure(name, opts?)` -> project -- Create project if not exists, return existing if found ### OIDC Applications - `c.apps:find(project_id, name)` -> app|nil -- Find OIDC app by name within project - `c.apps:create_oidc(project_id, opts)` -> app -- Create OIDC app. `opts`: `{name, subdomain, callbackPath, redirectUris, grantTypes, ...}` - `c.apps:ensure_oidc(project_id, opts)` -> app -- Create OIDC app if not exists ### Identity Providers - `c.idps:find(name)` -> idp|nil -- Find identity provider by name - `c.idps:ensure_google(opts)` -> idp_id|nil -- Ensure Google IdP. `opts`: `{clientId, clientSecret, scopes, providerOptions}` - `c.idps:ensure_oidc(opts)` -> idp_id|nil -- Ensure generic OIDC IdP. `opts`: `{name, clientId, clientSecret, issuer, scopes, ...}` - `c.idps:add_to_login_policy(idp_id)` -> bool -- Add IdP to organization login policy ### Users - `c.users:search(query)` -> [user] -- Search users by query table - `c.users:update_email(user_id, email)` -> bool -- Update user email (auto-verified) ### Login Policy - `c.login_policy:get()` -> policy|nil -- Get current login policy - `c.login_policy:update(policy)` -> bool -- Update login policy - `c.login_policy:disable_password()` -> bool -- Disable password-based login, enable external IdP Example: ```lua local zitadel = require("assay.zitadel") local c = zitadel.client({ url = "https://zitadel.example.com", domain = "example.com", machine_key_file = "/secrets/zitadel-key.json", }) local proj = c.projects:ensure("my-platform") local app = c.apps:ensure_oidc(proj.id, { name = "grafana", subdomain = "grafana", callbackPath = "/login/generic_oauth", }) ``` ## Workflow examples ### approval-pipeline # approval-pipeline A two-step workflow that pauses for a human approval signal between build and deploy. Demonstrates `ctx:wait_for_signal` and how a workflow can sit indefinitely without consuming worker resources. ## What it does ``` ApproveAndDeploy(input) ├─> activity "build" → produce {image, sha} ├─> wait_for_signal "approve" → receive {by = "alice"} └─> activity "deploy" → return {url, approver} ``` While waiting for `approve`, the workflow's status in the dashboard is `RUNNING` and an event `WorkflowAwaitingSignal` is recorded — but no worker is busy on it. Send the signal to wake it up. ## Run ```sh # Terminal 1 assay serve # Terminal 2 cd examples/workflows/approval-pipeline assay run worker.lua ``` Start a build: ```sh curl -X POST http://localhost:8080/api/v1/workflows \ -H 'Content-Type: application/json' \ -d '{ "workflow_type": "ApproveAndDeploy", "workflow_id": "deploy-prod-001", "task_queue": "default", "input": {"git_sha": "abc123", "target_env": "production"} }' ``` Watch — the workflow runs `build`, then sits at `WorkflowAwaitingSignal`. Approve it from the CLI: ```sh assay workflow signal deploy-prod-001 approve '{"by":"alice"}' ``` Within ~1s the workflow completes: ```sh curl -s http://localhost:8080/api/v1/workflows/deploy-prod-001 | jq .result # "{\"url\":\"https://.../deploy/...\",\"approver\":\"alice\"}" ``` You can also send the signal from any HTTP client: ```sh curl -X POST 'http://localhost:8080/api/v1/workflows/deploy-prod-001/signal/approve' \ -H 'Content-Type: application/json' \ -d '{"payload": {"by": "bob"}}' ``` **`worker.lua`** ```lua -- approval-pipeline — pause between activities for human approval. -- Run: assay run worker.lua (with `assay serve` running on :8080) local workflow = require("assay.workflow") workflow.connect(env.get("ASSAY_ENGINE_URL") or "http://localhost:8080") workflow.define("ApproveAndDeploy", function(ctx, input) -- Step 1: build local artifact = ctx:execute_activity("build", { ref = input.git_sha }) -- Step 2: wait indefinitely for an `approve` signal. The worker -- yields here; the workflow consumes no resources until a signal -- arrives via POST /workflows/:id/signal/approve. local approval = ctx:wait_for_signal("approve") -- Step 3: deploy return ctx:execute_activity("deploy", { image = artifact.image, env = input.target_env, approver = approval and approval.by or "unknown", }) end) workflow.activity("build", function(ctx, input) -- Real impl would call a CI system. Simulated for the example. return { image = "registry.example.com/app:" .. input.ref:sub(1, 8), sha = input.ref, } end) workflow.activity("deploy", function(ctx, input) -- Real impl would call k8s / nomad / etc. return { url = "https://" .. input.env .. ".example.com/app", approver = input.approver, } end) log.info("approval-pipeline worker ready") workflow.listen({ queue = "default" }) ``` ### hello-workflow # hello-workflow The simplest possible assay workflow: one activity that says hello. ## What it does ``` GreetWorkflow(input) └─> activity "greet" → { message = "hello, " .. input.who } return { greeting = ... } ``` ## Run In one terminal: ```sh assay serve ``` In another, from this directory: ```sh assay run worker.lua ``` In a third, start a workflow: ```sh curl -X POST http://localhost:8080/api/v1/workflows \ -H 'Content-Type: application/json' \ -d '{ "workflow_type": "GreetWorkflow", "workflow_id": "hello-1", "task_queue": "default", "input": {"who": "world"} }' ``` Within ~1 second: ```sh curl -s http://localhost:8080/api/v1/workflows/hello-1 | jq .result # "{\"greeting\":\"hello, world\"}" ``` The dashboard at shows it under **Workflows**; click the row to see the event timeline (`WorkflowStarted` → `ActivityScheduled` → `ActivityCompleted` → `WorkflowCompleted`). **`worker.lua`** ```lua -- hello-workflow — smallest possible assay workflow. -- Run: assay run worker.lua (with `assay serve` running on :8080) local workflow = require("assay.workflow") workflow.connect(env.get("ASSAY_ENGINE_URL") or "http://localhost:8080") workflow.define("GreetWorkflow", function(ctx, input) local r = ctx:execute_activity("greet", { who = input.who }) return { greeting = r.message } end) workflow.activity("greet", function(ctx, input) return { message = "hello, " .. input.who } end) log.info("hello-workflow worker ready — POST a workflow to start one") workflow.listen({ queue = "default" }) ``` ### nightly-report # nightly-report Cron-fired workflow that demonstrates the rest of the workflow engine in one example: a recurring schedule kicks off the parent, the parent uses `ctx:side_effect` to capture a non-deterministic report ID, scans for "anomalies", and starts a child workflow per anomaly to handle each in parallel-ish (each child runs independently against the same engine). ## What it does ``` NightlyReport(input) ← fired by cron ├─> side_effect "issue_report_id" → "rep-2026-04-16-XXXXX" ├─> activity "scan_anomalies" → returns [a1, a2, a3] └─> for each anomaly: start_child_workflow "HandleAnomaly" {anomaly} └─> activity "remediate" → {fixed = true, anomaly} return {report_id, anomalies_handled = 3} ``` `side_effect` makes the report ID stable across worker crashes — even if the worker dies after the report ID is generated but before the scan runs, the next worker re-replaying the workflow will see the same report ID from the event log instead of generating a new one. ## Run ```sh # Terminal 1 assay serve # Terminal 2 cd examples/workflows/nightly-report assay run worker.lua ``` Wire up the schedule (one-off; persists in the engine DB): ```sh # Fires every 5 seconds for the demo. Replace with "0 0 0 * * *" for # real nightly cadence (the cron crate wants 6/7 fields, with seconds). curl -X POST http://localhost:8080/api/v1/schedules \ -H 'Content-Type: application/json' \ -d '{ "namespace": "main", "name": "nightly-report", "workflow_type": "NightlyReport", "cron_expr": "*/5 * * * * *", "task_queue": "default", "input": {"region": "eu-west-1"} }' ``` Within ~15s (one scheduler tick) you'll see workflows appearing on the dashboard: - One `NightlyReport` workflow per fire - Three `HandleAnomaly` child workflows per `NightlyReport` Pause the schedule when you've seen enough: ```sh curl -X POST 'http://localhost:8080/api/v1/schedules/nightly-report/pause?namespace=main' ``` ## What to look for - **In the event log** of a `NightlyReport` workflow: `WorkflowStarted`, `SideEffectRecorded`, `ActivityScheduled` (scan), `ActivityCompleted`, `ChildWorkflowStarted` × 3, `ChildWorkflowCompleted` × 3, `WorkflowCompleted`. - **In a child** `HandleAnomaly`: `parent_id` set, normal activity-driven flow. - **In the workers view**: one worker, registered for the `default` queue, polling both workflow tasks and activity tasks. **`worker.lua`** ```lua -- nightly-report — cron + side_effect + child workflows in one file. -- Run: assay run worker.lua (with `assay serve` running on :8080) local workflow = require("assay.workflow") workflow.connect(env.get("ASSAY_ENGINE_URL") or "http://localhost:8080") -- The parent workflow: kicked off by the cron schedule. workflow.define("NightlyReport", function(ctx, input) -- side_effect lets us pull a fresh report ID without breaking -- determinism — the value is captured in the event log on the first -- replay and returned from cache thereafter, even if a worker crashes -- between this call and the next step. local report = ctx:side_effect("issue_report_id", function() return "rep-" .. tostring(os.time()) .. "-" .. tostring(math.random(10000, 99999)) end) local scan = ctx:execute_activity("scan_anomalies", { region = input.region, report_id = report, }) -- Spawn one HandleAnomaly child per anomaly. Each child workflow_id -- is deterministic (parent report id + anomaly index), so a replay -- finds the existing child instead of starting a new one. for i, anomaly in ipairs(scan.anomalies) do ctx:start_child_workflow("HandleAnomaly", { workflow_id = report .. "-anomaly-" .. tostring(i), input = { anomaly = anomaly, report_id = report }, }) end return { report_id = report, region = input.region, anomalies_handled = #scan.anomalies, } end) -- The child workflow: handles a single anomaly. workflow.define("HandleAnomaly", function(ctx, input) local r = ctx:execute_activity("remediate", input) return { fixed = r.fixed, anomaly = input.anomaly } end) -- Activities — these would do real work (DB scans, alerts, etc.) in a -- production setup. Here they're deterministic stand-ins. workflow.activity("scan_anomalies", function(ctx, input) -- Pretend we found three anomalies in this region return { region = input.region, anomalies = { { id = "a-1", kind = "stale_lock" }, { id = "a-2", kind = "missing_index" }, { id = "a-3", kind = "orphaned_record" }, }, } end) workflow.activity("remediate", function(ctx, input) -- Pretend we fixed it return { fixed = true, anomaly = input.anomaly } end) log.info("nightly-report worker ready — POST a schedule to fire it") workflow.listen({ queue = "default" }) ``` ## Optional - [Crates.io](https://crates.io/crates/assay-lua): Use Assay as a Rust crate - [Docker](https://github.com/developerinlondon/assay/pkgs/container/assay): ghcr.io/developerinlondon/assay:latest - [Agent Guides](https://assay.rs/agent-guides.html): Claude Code, Cursor, Windsurf, Cline, OpenCode - [Changelog](https://github.com/developerinlondon/assay/releases): Release history