Build a provisioner with the starter
Goal
For platform teams. In this task you write a provisioner for one type. The starter library answers the HTTP calls of the contract for you. You write only what happens to the real resource.
Before you start
This is not available yet. The starter library has no download. You can write a provisioner without it: Provisioner API reference describes every call that the control plane makes.
- You need a Kotlin or Java project with Spring Boot, and the starter as a dependency. Its Maven coordinates are
com.joomiz:joomiz-provisioner-starter. - Choose the type that your provisioner handles, for example the category
managed-resourceand the typepostgres. The type must be in the catalog. Own the catalog of types explains it. - To register by hand, you need the role
operator. Register a provisioner shows how.
Steps
Know what the starter does
The starter adds the HTTP side of the contract to a Spring Boot application. It:
- Serves
POST /plan,POST /applyandPOST /inspect. - Reads each request, and refuses a request that is not valid with
400. - Writes the answer in the shape that the contract needs.
- Answers
200with the outcomefailedwhen your code reports a failure. - Refuses a request body that is too large.
- Can register the provisioner with the control plane when it starts.
- Can serve
GET /operations/{operationId}for slow work, when you add aProvisionerOperationsbean. Support slow work in a provisioner shows the interface and a skeleton.
Without your own class, the starter answers success for every change and provisions nothing. Your class replaces that behavior.
Write the main function
A provisioner is a Spring Boot application. The whole main function is:
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class PostgresProvisionerApplication
fun main(args: Array<String>) {
runApplication<PostgresProvisionerApplication>(*args)
}
Name the type in application.yml
The file application.yml says which pair the provisioner handles:
spring:
application:
name: postgres-provisioner
server:
port: 8080
provisioner:
stub:
kind: managed-resource
type: postgres
management:
endpoints:
web:
exposure:
include: health
The settings of the starter begin with provisioner.stub. This is the name of the starter, also for a real provisioner. The keys kind and type name the pair. The health setting makes GET /actuator/health answer, so that a platform can check the provisioner.
A provisioner that handles several pairs supplies its own behavior, and it does not set a single pair here.
Write the class that does the work
Write one class that implements ProvisionerBehavior, and make it a Spring bean. The starter then stops using its own answer. The interface has three methods:
interface ProvisionerBehavior {
fun plan(changeSet: ResourceChangeSet): ProvisionerOutcome
fun apply(changeSet: ResourceChangeSet): ProvisionerOutcome
fun inspect(request: ResourceInspectRequest): ResourceInspectOutcome
}
This is a skeleton. It reports success for each change and does no real work:
import com.joomiz.provisioner.api.Outcome
import com.joomiz.provisioner.api.ProvisionerOutcome
import com.joomiz.provisioner.api.ProvisionerOutcomes
import com.joomiz.provisioner.api.ResourceChange
import com.joomiz.provisioner.api.ResourceChangeSet
import com.joomiz.provisioner.api.ResourceInspectOutcome
import com.joomiz.provisioner.api.ResourceInspectRequest
import com.joomiz.provisioner.api.ResourceInspectResult
import com.joomiz.provisioner.behavior.ProvisionerBehavior
import org.springframework.stereotype.Component
@Component
class PostgresBehavior : ProvisionerBehavior {
override fun plan(changeSet: ResourceChangeSet): ProvisionerOutcome =
outcomeOf(changeSet) { change -> "Would handle '${change.id}'. Nothing was changed." }
override fun apply(changeSet: ResourceChangeSet): ProvisionerOutcome =
outcomeOf(changeSet) { change -> "Handled '${change.id}'." }
override fun inspect(request: ResourceInspectRequest): ResourceInspectOutcome =
ResourceInspectOutcome(
request.resources.map { ResourceInspectResult.unknown(it.id, "This provisioner cannot read the real resource.") },
)
private fun outcomeOf(changeSet: ResourceChangeSet, describe: (ResourceChange) -> String) = ProvisionerOutcome(
outcome = Outcome.SUCCEEDED,
description = "Handled ${changeSet.changes.size} change(s).",
results = changeSet.changes.map { change ->
ProvisionerOutcomes.resourceResult(change.id, change.environment, Outcome.SUCCEEDED, describe(change), outputs = null)
},
)
}
Your class must follow these rules:
planchanges nothing. It describes whatapplywould do.applygives the same end state when it is repeated. The control plane can send the same change set again after a failure.inspectreports what really exists, and it changes nothing. Answerpresentwith the settings that you can read,absent, orunknownwith a short reason. Never answerpresentwith a guess. A provisioner that cannot read a resource answersunknown.- Return one result for each change, with the
idof the change. The control plane counts a change with no result as failed. - Answer
failedfor a pair that you do not handle. The starter makes this check only in its own answer. If you supply your own class, your class must make it. - Report a failure that you can describe as
failed. Do not throw an exception. An exception becomes500, and the control plane loses every result. - Check every value before you use it. The values of
speccome from a user. Do not put them into a command, a path or a query without checking them against what your system accepts. - Report a sensitive output as an address. A password or a key is never an output value. Store it in a vault, and report an
https://address with no user name and no password that names where it is stored.
Run it
Build the project, and start it as any Spring Boot application. When it is ready, the health address answers:
curl -s -w '\nHTTP %{http_code}\n' "$PROVISIONER_URL/actuator/health"
{"groups":["liveness","readiness"],"status":"UP"}
HTTP 200
PROVISIONER_URL holds the address of your provisioner.
Register it
Register the provisioner by hand, as Register a provisioner shows. Or let it register itself at start. Set these properties, with the address of your control plane and the address of the provisioner as the control plane sees it:
provisioner.stub.self-register.enabled=true
provisioner.stub.self-register.control-plane-url=https://joomiz.example.com
provisioner.stub.self-register.url=https://postgres-provisioner.example.com
The name of the registration is the value of spring.application.name. To choose another name, set provisioner.stub.self-register.name.
The registration needs a token with the role operator. Give it in this environment variable, from the secret store of your platform:
PROVISIONER_STUB_SELFREGISTER_TOKEN
Do not write it in a file of your project. Without a token, the control plane answers 401, the provisioner logs it, and it keeps running unregistered.
The start fails, and names the property, if control-plane-url or url is missing or is not an http or https address. The registration is tried up to 10 times, 3 seconds apart. You can change both values with provisioner.stub.self-register.attempts and provisioner.stub.self-register.retry-delay.
Test it against the contract
Send a request to each call, as the control plane does. This is a change set for one database. Save it as plan-request.json:
{
"applicationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "managed-resource",
"resourceType": "postgres",
"changes": [
{
"id": "shop-db",
"environment": "test",
"action": "created",
"description": "Create a PostgreSQL database named shop-db.",
"spec": {
"flavor": "cf-plan-small",
"version": "16",
"storage-gb": 20,
"multi-az": false
},
"unresolvedFields": []
}
]
}
Send it to POST /plan:
curl -s -w '\nHTTP %{http_code}\n' -X POST "$PROVISIONER_URL/plan" -H 'Content-Type: application/json' -d @plan-request.json
This is the answer of the skeleton above:
{"outcome":"succeeded","description":"Handled 1 change(s).","results":[{"id":"shop-db","environment":"test","outcome":"succeeded","description":"Would handle 'shop-db'. Nothing was changed.","outputs":null}]}
HTTP 200
Send the same file to POST /apply. Then ask POST /inspect what exists. Provisioner API reference shows the requests and the answers of every call, and the answers for a request that is not valid.
Check
GET /actuator/healthanswersUP.POST /planwith the change set above answers200with"outcome": "succeeded"and one result forshop-db.- The registration shows in the dashboard under Provisioners, or in the list of
GET /provisioners. joomiz planfor a file that uses your type shows your answer in the plan.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
The start fails and names provisioner.stub.kind | The pair is not named, and you did not write your own behavior. | Set kind and type, or supply your own ProvisionerBehavior. |
The start fails and names self-register.url | Self-registration is on, and an address is missing. | Set control-plane-url and url. |
The log shows a 401 for the registration | The token is missing or has no operator role. | Set the token in the environment variable. |
The log shows a 400 for the address | The control plane may not call the address. | Allow the address of your provisioner. |
A plan says the provisioner sent an unexpected response | The answer does not follow the contract, or the call was not sent. | Compare your answer with Provisioner API reference. |
The control plane says reported no result for this resource | Your answer has no result for a change. | Return one result for each id. |
Next
Read how a provisioner reports work that takes minutes in Support slow work in a provisioner.