Your first provisioning recipe
This walks you through writing, validating, and shipping a provisioning recipe end-to-end. By the end you'll have a working provision.json for a service called scaifoo and you'll know enough to do the same for any new ScaiLabs service.
For the conceptual model behind every step in this tutorial, see Concepts → Provisioning workflows.
What you'll build#
A 6-step provisioning recipe for a hypothetical scaifoo service:
When a tenant subscribes to scaifoo, ScaiControl will:
- Verify
scaikeyis already provisioned (a prerequisite for scaifoo) - Mint a
(client_id, client_secret)pair scoped to the tenant - Store the credentials in ScaiVault
- Call scaifoo's provisioning API to create the tenant on its side (async — scaifoo callbacks when done)
- Verify scaifoo's health endpoint comes back 200
- Email the tenant admin that provisioning is complete
Prerequisites#
- A local checkout of the service repo where this recipe will live (
scaifoo/). - The
scaicontrolCLI on your PATH (pip install -e scaicontrol/backend/in a venv). - Optional: the sandbox stack running locally for end-to-end testing — see Sandbox.
You don't need a database connection for validate — that's by design. CI in your service repo can validate the JSON without any ScaiControl infrastructure.
Step 1 — scaffold the file#
Create scaifoo/provisioning/provision.json with just the wrapper:
1 2 3 4 5 | |
The two top-level fields are workflow-wide defaults: max_retries is the global retry budget for the whole instance (not per-step — steps get their own); timeout_seconds is the workflow's hard ceiling.
Validate:
1 | |
You'll get a single error: EMPTY_DEFINITION — definition must include a non-empty steps[] array. Expected.
Step 2 — first step: validate dependencies#
Scaifoo depends on scaikey being provisioned first. Add the preflight check:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
A few things to notice:
namemust be unique within the workflow. Use snake_case; the validator doesn't enforce that, but it makes input-mapping references readable.handler_classis a short name resolved against the handler registry.ValidateDependenciesis one of the seven built-ins.configis handler-specific. Thedependencieskey tellsValidateDependencieswhich service slugs to check. The shape is documented per handler in the handlers reference (GET /admin/provisioning/handlers) or by clicking the handler in the visual designer's Handlers tab.
Re-validate:
1 | |
Now you get OK — definition is valid with a single warning: NO_COMPENSATE_HANDLER — step 'validate' has no compensate_handler. That's fine — validate has no side effects, nothing to compensate.
Step 3 — mint credentials#
Add the credentials step. It depends on validate and produces outputs that downstream steps will need:
1 2 3 4 5 6 7 8 | |
compensate_handler points back at the same class — the handler implements both execute() (mint) and compensate() (revoke). On rollback after a partial provisioning failure, the credential gets cleanly revoked instead of orphaned.
What does this step produce? Click the handler in the visual designer (or fetch GET /admin/provisioning/handlers and find the entry for GenerateServiceCredentials):
1 2 3 4 | |
We'll wire those into the next two steps.
Step 4 — store in vault#
The credentials go into ScaiVault at a tenant-derived path. This step depends on gen_creds and consumes its outputs:
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
The input_mapping is the explicit wire between steps. Format:
gen_creds.client_id— pull from the upstream step'sresultdict, keyclient_id.context.tenant_id— pull from the workflow's frozen context.
The vault path template uses {tenant_id} etc. — those are interpolated from context by the handler itself (not by the engine). Check each handler's docs for which template tokens it accepts.
Step 5 — call scaifoo's provisioning API#
This is the async part. ScaiControl POSTs to scaifoo's provisioning_api_url with the tenant + credentials, and waits for scaifoo to callback /api/v1/provisioning/callback with the result.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
What changed:
typeisasync_callback(notsync). The handler returns immediately withstatus: waiting_callback. The step sits there until scaifoo POSTs back with the matchingcallback_token.depends_onis["store_vault"], butinput_mappingreferencesgen_creds.client_id— that's allowed becausestore_vaultitself depends ongen_creds, sogen_credsis in this step's transitive dep set. The validator does NOT enforce direct-dep wiring for inputs; if you'd rather be strict, addgen_credstodepends_onexplicitly.compensate_handler: "CallDeprovisioningApi"— on rollback, ScaiControl asks scaifoo to tear down whatever it created.- Bigger retry budget + longer timeout — async network calls deserve more headroom.
Step 6 — health check + notify#
Two final sync steps:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
health_check GETs the service's health_check_url (from the service registry, available in context). It has its own internal retry-with-backoff via the retries config key.
notify is a fire-and-forget — failure here doesn't unwind the provisioning.
Step 7 — validate the full recipe#
1 | |
Expected output:
1 2 3 4 | |
Warnings are fine — they're for steps that don't need compensation. Errors would block import.
For CI integration, use --format json to get machine-readable output:
1 | |
Exits non-zero on any error, so it's pipeline-friendly.
Step 8 — test in the sandbox#
Before shipping to prod, run the recipe end-to-end against a local stack. See Sandbox for setup. Quick version:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
Watch the resulting workflow at http://localhost:8001/admin/provisioning → Workflows tab. Each step should turn green; the call_provisioning_api step will sit on waiting_callback until scaifoo's mock POSTs the callback.
Step 9 — deploy to prod#
CI in your service repo runs validate on every PR. On merge, your CD pipeline imports against prod:
1 2 3 | |
Output: Imported scaifoo/provision v1 (id=<uuid>).
From this point on, every new subscription to scaifoo triggers your recipe. Re-running the import with identical content is a no-op (No-op: identical to existing v1); changing the content auto-bumps to v2.
Editing later#
The big rule from Concepts: once any workflow has attached to a definition, the steps_definition is immutable. Edits go through clone-as-new-version, either:
- CLI: edit the JSON file, then
scaicontrol admin workflow-defs importagain — auto-bumps. - Admin UI: open
/admin/provisioning→ Definitions →Clone & Editon the current row → make changes → save. Pops out as v+1.
Old in-flight workflows keep running on the version they started on. New ones pick up the new version.
Authoring in the visual designer instead#
Everything above can be done in the designer instead of by hand-editing JSON:
- Click
+ New Definitionon the Definitions tab. - Fill in
service_slug+workflow_type. - Click handlers in the left palette to drop step nodes onto the canvas.
- Drag from a node's bottom port to another node's top port to wire
depends_on. - Click a step → property panel on the right. Edit
name,handler_class,type, retries, etc. Theconfigform is auto-rendered from the handler's declaredconfig_schema. Theinput_mappingform lets you pick source step + output via dropdowns — picking a step that isn't yet a dep wires the edge for you. Auto-layoutre-flows nodes into clean layers.Validateround-trips to the same validator as the CLI.Createsaves.
The visual designer and the JSON file produce the same steps_definition — they're two interfaces over one underlying model. The "Visual" / "JSON" tab toggle inside the editor switches between them mid-edit.
Common pitfalls#
| Symptom | Cause | Fix |
|---|---|---|
HANDLER_NOT_FOUND on a name you expected to exist |
Handler isn't in the built-ins and the entry-point plugin isn't installed on this ScaiControl instance | Either fix the handler name, or have the service team ship a package with the right [project.entry-points."scaicontrol.provisioning.handlers"] |
CYCLE error |
A → B → A somewhere in depends_on |
Walk the listed steps to find the loop; visual designer highlights the affected nodes |
Step stuck on waiting_callback |
Service never POSTed /provisioning/callback, or used the wrong callback_token |
Check the service's logs. The token lives on the step row + the workflow row — service should grab it from the body of the inbound provisioning request |
Step retries to exhaustion + workflow ends failed |
Genuine handler failure | Admin recovery: /admin/provisioning → Workflows → expand row → Retry Step. Once you've fixed the underlying problem, retry resets status=pending and the scheduler picks it up |
IDEMPOTENCY_KEY_MISMATCH from a write |
A retry sent the same key with different body | The retry layer in your call site is wrong — the body must be byte-identical across retries |
| Subscription is created but no workflow appears | No active definition for (service, type) |
scaicontrol admin workflow-defs list --service scaifoo --include-inactive — confirm a v1 is active |
See also#
- Concepts → Provisioning workflows — the model behind the moving parts.
- Reference → CLI — every
workflow-defssubcommand. - Reference → admin-provisioning API — endpoints, request/response schemas.
- Reference → state machines — workflow + step lifecycles, transitions.
- Sandbox — local docker-compose stack for safe end-to-end runs.