Tenant provisioning
This is an example of an IT workflow. When a new tenant is requested, a manager has to approve it, and three subsystems are provisioned in parallel before the tenant is verified and welcomed. Two of the three are complex enough to be workflows of their own. If anything fails after approval, everything provisioned so far is torn down, including whatever the children built.
Children workflow definitions#
Each child keeps its own status, compensation stack, and timers. WithOutput names the step whose output the parent reads back.
provisionDatabase, err := workflow.New("provision-database",
workflow.WithOutput("credentials"),
workflow.WithSteps(
workflow.Step("create-cluster",
workflow.WithRun(createCluster),
workflow.WithCompensate(deleteCluster),
workflow.WithStepTimeout(10*time.Minute),
// Only hosts advertising "cloud-api" run this, and its undo runs on one too
workflow.WithRequiredCapability("cloud-api"),
),
workflow.Step("create-schema", workflow.WithRun(createSchema)),
workflow.Step("credentials",
workflow.WithRun(issueCredentials),
workflow.WithCompensate(revokeCredentials),
),
),
)
provisionStorage, err := workflow.New("provision-storage",
workflow.WithOutput("bucket"),
workflow.WithSteps(
workflow.Step("bucket", workflow.WithRun(createBucket), workflow.WithCompensate(deleteBucket)),
workflow.Step("policy", workflow.WithRun(attachPolicy), workflow.WithCompensate(detachPolicy)),
),
)Parent workflow definition#
onboarding, err := workflow.New("tenant-onboarding",
workflow.WithTimeout(7*24*time.Hour),
workflow.WithSteps(
// Opens the review ticket
// Its compensation closes it with the reason
workflow.Step("request-review",
workflow.WithRun(openReviewTicket),
workflow.WithCompensate(closeReviewTicket),
),
// Parks the instance until a manager approves, or three days pass
workflow.WaitForEvent("approval",
workflow.WithEventTimeout(72*time.Hour),
),
// The approval payload says whether it was approved
workflow.Step("approved", workflow.WithRun(readApproval)),
// Three subsystems at once, two of them workflows in their own right
workflow.Parallel("provision",
workflow.Child("database", workflow.WithDefinition(provisionDatabase)),
workflow.Child("storage", workflow.WithDefinition(provisionStorage)),
workflow.Step("dns", workflow.WithRun(createDNS), workflow.WithCompensate(deleteDNS)),
),
workflow.Step("verify",
workflow.WithRun(verifyTenant),
workflow.WithInputFrom("provision"),
workflow.WithSkipIf("approved", false),
),
workflow.Step("welcome", workflow.WithRun(sendWelcome), workflow.WithOptional()),
),
)Register all three on every host that should run their steps:
err = host.RegisterBuiltInActor(provisionDatabase)
err = host.RegisterBuiltInActor(provisionStorage)
err = host.RegisterBuiltInActor(onboarding)Handlers#
readApproval turns the event payload into the boolean the condition reads:
type approvalPayload struct {
Approved bool `json:"approved"`
By string `json:"by"`
Note string `json:"note,omitempty"`
}
func readApproval(ctx context.Context, t workflow.Task) (any, error) {
var payload approvalPayload
err := t.DecodeOutput("approval", &payload)
if err != nil {
return nil, errors.Join(actor.ErrJobPermanentFailure, err)
}
// A rejection is a recorded output, which is what WithSkipIf reads on the step below
return payload.Approved, nil
}verifyTenant reads the parallel group’s output through WithInputFrom("provision"): the database child’s credentials under database, the storage child’s bucket under storage, and the DNS record under dns.
type provisionOutput struct {
Database databaseCredentials `json:"database"`
Storage bucketRef `json:"storage"`
DNS dnsRecord `json:"dns"`
}
func verifyTenant(ctx context.Context, t workflow.Task) (any, error) {
var provisioned provisionOutput
err := t.DecodeOutput("provision", &provisioned)
if err != nil {
return nil, errors.Join(actor.ErrJobPermanentFailure, err)
}
return nil, healthcheck.Tenant(ctx, provisioned.Database, provisioned.Storage, provisioned.DNS)
}Starting and driving the workflow#
svc := onboarding.Service(host.Service())
// One instance per tenant, so a retried request finds the run already in flight
id, _, err := svc.Start(ctx, tenantRequest{Name: name, Plan: plan}, workflow.WithInstanceID(tenantID))When the workflow waits for the external approval, notify the manager in some ways (e.g. an email, a Slack/Teams notification, etc). The approval arrives from outside, as a single call, for example through a webhook that’s invoked from the Slack/Teams message:
err = svc.RaiseEvent(ctx, tenantID, "approval", approvalPayload{Approved: true, By: user, Note: note})Example failures#
Example failures and how they are handled:
Nobody approves within three days.
The event timeout elapses. The stack holds one entry,request-review, socloseReviewTicketruns with the causeevent "approval" timed outand the instance terminatesfailed. Nothing was provisioned.The manager rejects.
RaiseEventcarriesApproved: false, the wait completes,readApprovalreturnsfalse, andWithSkipIfskipsverify. The provision group still runs, since in this graph a rejected tenant is provisioned in a sandbox, and the instance completes withverifyrecorded asskipped.Storage’s
policystep fails permanently after the database child has completed.
The storage child deletes its bucket and reports failure, andFailFastfails theprovisiongroup.The parent’s stack holds the group and
request-review.
Rolling back the group asks the completed database child to undo itself, which revokes its credentials and deletes its cluster, and runsdeleteDNSat the same time. The ticket is closed last. The parent isfailedwithcompensation: completed.// Both children, in their terminal states page, err := dbSvc.List(ctx, &workflow.ListOptions{Parent: tenantID})The cloud API is under maintenance mid-provisioning.
An operator suspends the parent:err = svc.Suspend(ctx, tenantID, "cloud maintenance")That stops it starting anything new. The children keep running, and their results wait. The seven-day instance timeout is paused, and
Resumecontinues where the instance left off.