Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Modellen Tools & Services
Oplossingen
Organisaties Ontwikkelaars Internet Service Providers Managed Service Providers AI-in-a-Box
Kenniscentrum
Ondersteuning Documentation Blog Downloads
Bedrijf
Over ons Onderzoek Vacatures Investeren Contact
Inloggen

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:

flowchart TD A[validate] --> B[gen_creds] B --> C[store_vault] B --> D[call_provisioning_api] C --> D D --> E[health_check] E --> F[notify]

When a tenant subscribes to scaifoo, ScaiControl will:

  1. Verify scaikey is already provisioned (a prerequisite for scaifoo)
  2. Mint a (client_id, client_secret) pair scoped to the tenant
  3. Store the credentials in ScaiVault
  4. Call scaifoo's provisioning API to create the tenant on its side (async — scaifoo callbacks when done)
  5. Verify scaifoo's health endpoint comes back 200
  6. Email the tenant admin that provisioning is complete

Prerequisites#

  • A local checkout of the service repo where this recipe will live (scaifoo/).
  • The scaicontrol CLI 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:

json
1
2
3
4
5
{
  "max_retries": 3,
  "timeout_seconds": 3600,
  "steps": []
}

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:

bash
1
scaicontrol admin workflow-defs validate scaifoo/provisioning/provision.json

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:

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  "max_retries": 3,
  "timeout_seconds": 3600,
  "steps": [
    {
      "name": "validate",
      "type": "sync",
      "handler_class": "ValidateDependencies",
      "depends_on": [],
      "config": {
        "dependencies": ["scaikey"]
      }
    }
  ]
}

A few things to notice:

  • name must be unique within the workflow. Use snake_case; the validator doesn't enforce that, but it makes input-mapping references readable.
  • handler_class is a short name resolved against the handler registry. ValidateDependencies is one of the seven built-ins.
  • config is handler-specific. The dependencies key tells ValidateDependencies which 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:

bash
1
scaicontrol admin workflow-defs validate scaifoo/provisioning/provision.json

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:

json
1
2
3
4
5
6
7
8
{
  "name": "gen_creds",
  "type": "sync",
  "handler_class": "GenerateServiceCredentials",
  "depends_on": ["validate"],
  "config": { "credential_type": "api_key", "key_length": 32 },
  "compensate_handler": "GenerateServiceCredentials"
}

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):

carbon
1
2
3
4
produces_outputs:
  credential_type: str
  client_id:       str
  client_secret:   str

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:

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "name": "store_vault",
  "type": "sync",
  "handler_class": "StoreCredentialsInVault",
  "depends_on": ["gen_creds"],
  "config": {
    "vault_path_template": "tenants/{tenant_id}/services/scaifoo"
  },
  "input_mapping": {
    "client_id":     "gen_creds.client_id",
    "client_secret": "gen_creds.client_secret"
  }
}

The input_mapping is the explicit wire between steps. Format:

  • gen_creds.client_id — pull from the upstream step's result dict, key client_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.

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
  "name": "call_provisioning_api",
  "type": "async_callback",
  "handler_class": "CallProvisioningApi",
  "depends_on": ["store_vault"],
  "config": {
    "method": "POST",
    "path": "/v1/tenants",
    "timeout": 30
  },
  "input_mapping": {
    "client_id":     "gen_creds.client_id",
    "client_secret": "gen_creds.client_secret"
  },
  "compensate_handler": "CallDeprovisioningApi",
  "max_retries": 5,
  "retry_delay_seconds": 60,
  "timeout_seconds": 600
}

What changed:

  • type is async_callback (not sync). The handler returns immediately with status: waiting_callback. The step sits there until scaifoo POSTs back with the matching callback_token.
  • depends_on is ["store_vault"], but input_mapping references gen_creds.client_id — that's allowed because store_vault itself depends on gen_creds, so gen_creds is in this step's transitive dep set. The validator does NOT enforce direct-dep wiring for inputs; if you'd rather be strict, add gen_creds to depends_on explicitly.
  • 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:

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "name": "health_check",
  "type": "sync",
  "handler_class": "HealthCheck",
  "depends_on": ["call_provisioning_api"],
  "config": { "timeout": 10, "expected_status": 200, "retries": 3, "retry_delay": 2.0 }
},
{
  "name": "notify",
  "type": "sync",
  "handler_class": "SendProvisioningNotification",
  "depends_on": ["health_check"],
  "config": { "template": "provisioning_complete", "channel": "email" }
}

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#

bash
1
scaicontrol admin workflow-defs validate scaifoo/provisioning/provision.json

Expected output:

tsql
1
2
3
4
OK  definition is valid.
  [warning] NO_COMPENSATE_HANDLER  at /steps/0/compensate_handler  step 'validate' has no compensate_handler  rollback will skip it
  [warning] NO_COMPENSATE_HANDLER  at /steps/3/compensate_handler  step 'health_check' has no compensate_handler  rollback will skip it
  [warning] NO_COMPENSATE_HANDLER  at /steps/5/compensate_handler  step 'notify' has no compensate_handler  rollback will skip it

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:

bash
1
scaicontrol admin workflow-defs validate provision.json --format json | jq '.ok'

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:

bash
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
docker compose -f tools/sandbox/docker-compose.sandbox.yml up -d
python tools/sandbox/seed.py

# Import the recipe into the sandbox DB:
DATABASE_URL=mysql+pymysql://root:sandbox@localhost:3307/scaicontrol_sandbox \
  scaicontrol admin workflow-defs import scaifoo/provisioning/provision.json \
    --service scaifoo --type provision

# Trigger a provisioning run by creating a sandbox subscription on scaifoo
# (use the admin API or the seeded tenant_admin user via the portal):
curl -X POST http://localhost:8001/api/v1/admin/subscriptions \
  -H "Authorization: Bearer $SANDBOX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "tenant_id":   "tnt_sandbox_platform_a",
    "partner_id":  "prt_sandbox_platform",
    "service_slug":"scaifoo",
    "plan_id":     "<plan-uuid>"
  }'

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:

bash
1
2
3
scaicontrol admin workflow-defs import scaifoo/provisioning/provision.json \
    --service scaifoo --type provision \
    --description "Initial provisioning recipe for scaifoo"

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 import again — auto-bumps.
  • Admin UI: open /admin/provisioning → Definitions → Clone & Edit on 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:

  1. Click + New Definition on the Definitions tab.
  2. Fill in service_slug + workflow_type.
  3. Click handlers in the left palette to drop step nodes onto the canvas.
  4. Drag from a node's bottom port to another node's top port to wire depends_on.
  5. Click a step → property panel on the right. Edit name, handler_class, type, retries, etc. The config form is auto-rendered from the handler's declared config_schema. The input_mapping form lets you pick source step + output via dropdowns — picking a step that isn't yet a dep wires the edge for you.
  6. Auto-layout re-flows nodes into clean layers.
  7. Validate round-trips to the same validator as the CLI.
  8. Create saves.

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#

Updated 2026-06-29 00:34:16 View source (.md) rev 4