Compound Promises with Waits
Compound Promises are Promises that depend on other Promises to deliver their promised service. The ability to chain Promises together allows platform teams to deliver entire stacks on demand, while keeping each sub-Promise small and focused on its own service.
In this tutorial, you will
- Encapsulate multiple Promises into a Compound Promise with waits
- Request a complete development environment Resource through a Compound Promise
For a quick overview of Workflow Controls and Waits, watch the video below:
Pre-requisites
You need an installation of Kratix for this section. Click here for instructions
The simplest way to do so is by running the quick-start script from within the Kratix directory. The script will create two KinD clusters, install, and configure Kratix.
./scripts/quick-start.sh --recreate
You can run Kratix either with a multi-cluster or a single-cluster setup. The commands on the remainder of this document assume that two environment variables are set:
PLATFORMrepresenting the platform cluster Kubernetes contextWORKERrepresenting the worker cluster Kubernetes context
If you ran the quick-start script above, do:
export PLATFORM="kind-platform"
export WORKER="kind-worker"
For single cluster setups, the two variables should be set to the same value. You can find your cluster context by running:
kubectl config get-contexts
Refer back to Installing Kratix for more details.
Register the Platform as a Destination
Compound Promises work by scheduling their workflow outputs to the Platform cluster itself. For that to work, you will need to register the Platform cluster as a Destination, and run a GitOps Agent in the Platform cluster listening to the State Store associated with the platform destination.
Create a new Destination document - platform-cluster.yaml - with the following contents:
apiVersion: platform.kratix.io/v1alpha1
kind: Destination
metadata:
name: platform-cluster
labels:
environment: platform
spec:
path: platform-cluster
strictMatchLabels: true
stateStoreRef:
name: default
kind: BucketStateStore
Register the Destination:
kubectl --context $PLATFORM apply --filename platform-cluster.yaml
Install and configure GitOps
Now that your Destination is registered, make sure to install the GitOps Agent into your Platform cluster. The quickest way to do that is to run the ./scripts/install-gitops script from the Kratix root directory:
cd /path/to/kratix
./scripts/install-gitops --context $PLATFORM --path platform-cluster
Building the Compound Promise
You can follow this guide and build the Promise along with us, or you can use it as a reference when building your own Compound Promises.
The full Promise mentioned in this guide is also available on the Kratix Marketplace.
Implementing the API
Let's create the AppStack Promise. We can use the Kratix CLI to speed up development. Create a new directory in your system and initialise a new Promise:
mkdir app-stack-example && cd app-stack-example
kratix init promise app-stack \
--group example.kratix.io \
--kind AppStack \
--version v1alpha1
The command above should produce a promise.yaml in the app-stack-example directory. We can now add the API properties we defined above:
kratix update api \
--property image:string \
--property database.driver:string
kratix update destination-selector environment=platform
The next step is to implement the workflows that will transform the user's request into the sub-promises request.
Our promise will consist of three pipelines:
The create-dependencies pipeline:
which is responsible in creating the specified sub-promise requests:
kratix add container resource/configure/create-dependencies \
--image kratix-guide/app-stack-create-dependencies:v0.1.0 \
--name create-dependencies
At this point, your local app-stack-example directory should look like this:
.
├── README.md
├── example-resource.yaml
├── promise.yaml
└── workflows
└── resource
└── configure
└── create-dependencies
└── create-dependencies
├── Dockerfile
├── resources
└── scripts
└── pipeline.sh
8 directories, 5 files
This pipeline's script should do:
- Create a PostgreSQL instance if
database.driveris set topostgresql, via the PostgreSQL Promise. - Write
/kratix/metadata/status.yamlwith:- whether PostgreSQL was requested
- the generated PostgreSQL request name
Let's rename the pipeline.sh to create-dependencies to better reflect its purpose.
mv workflows/resource/configure/create-dependencies/create-dependencies/scripts/{pipeline.sh,create-dependencies}
Make sure to update the Dockerfile to reflect the new script name. Replace the Dockerfile contents with the snippet below:
FROM alpine:3.20
RUN apk update && apk add --no-cache yq
COPY scripts/* /usr/local/bin/
ADD resources resources
RUN chmod +x /usr/local/bin/*
CMD [ "create-dependencies" ]
ENTRYPOINT []
Adding the PostgreSQL Request
Next step is to optionally include a request to the PostgreSQL Promise if the user requests a database. The API for the PostgreSQL Promise looks like this:
apiVersion: marketplace.kratix.io/v1alpha1
kind: postgresql
metadata:
name: example
namespace: default
spec:
env: dev
teamId: acid
dbName: bestdb
Back in the create-dependencies pipeline script, let's update it to include this request when needed. Add the following code snippet:
#!/usr/bin/env sh
set -eu
app_name="$(yq eval '.metadata.name' /kratix/input/object.yaml)"
app_namespace="$(yq eval '.metadata.namespace // "default"' /kratix/input/object.yaml)"
pg_safe_name="$(printf '%s' "$app_name" | tr '-' '_')"
database_requested=false
database_driver="$(yq eval '.spec.database.driver // ""' /kratix/input/object.yaml)"
k8s_resource_name="${app_name}-db" # e.g., app-stack-db (For Kubernetes)
pg_database_name="${pg_safe_name}_db" # e.g., app_stack_db (For Postgres)
if [ "${database_driver}" = "postgresql" ]; then
database_requested=true
cat > /kratix/output/postgresql-request.yaml <<EOF
apiVersion: marketplace.kratix.io/v1alpha2
kind: postgresql
metadata:
name: ${k8s_resource_name}
namespace: ${app_namespace}
labels:
kratix.io/component-of-promise-name: app-stack
kratix.io/component-of-resource-name: ${app_name}
kratix.io/component-of-resource-namespace: ${app_namespace}
spec:
env: dev
teamId: ${app_name}
dbName: ${pg_database_name}
EOF
fi
Additionally in this pipeline script we need to write /kratix/metadata/status.yaml with:
- whether PostgreSQL was requested
- the generated PostgreSQL request name
Add the following:
cat > /kratix/metadata/status.yaml <<EOF
message: Dependency requests created
dependencies:
database:
requested: ${database_requested}
requestName: "${k8s_resource_name}"
EOF
We also need to build and load this image into our local environment:
docker build -t kratix-guide/app-stack-create-dependencies:v0.1.0 workflows/resource/configure/create-dependencies/create-dependencies/
kind load docker-image kratix-guide/app-stack-create-dependencies:v0.1.0 --name platform
And that's it. This workflow is now done!
The next pipeline is wait-for-dependencies
This pipeline is responsible for reading the status written by create-dependencies, and do the following operations
- If PostgreSQL was requested:
- verify the PostgreSQL resource is ready
- If the requested dependency is not ready:
- write
/kratix/metadata/workflow-control.yaml - include
retryAfter - optionally include
maxAttemptsandmessage
- write
- If the requested dependency is ready:
- write
/kratix/metadata/status.yamlwith the resolved values needed by the Runtime request.
- write
Let's create the pipeline:
kratix add container resource/configure/wait-for-dependencies \
--image kratix-guide/app-stack-wait-for-dependencies:v0.1.0 \
--name wait-for-dependencies
Same as before, let's rename the pipeline.sh to wait-for-dependencies to better reflect its purpose.
mv workflows/resource/configure/wait-for-dependencies/wait-for-dependencies/scripts/{pipeline.sh,wait-for-dependencies}
Make sure to update the Dockerfile to reflect the new script name. Replace the Dockerfile contents with the snippet below:
FROM alpine:3.20
RUN apk update && apk add --no-cache kubectl yq
COPY scripts/* /usr/local/bin/
RUN chmod +x /usr/local/bin/*
CMD [ "wait-for-dependencies" ]
ENTRYPOINT []
Now let's implement the functionality. In the wait-for-dependencies add the following:
#!/usr/bin/env sh
set -eu
app_name="$(yq eval '.metadata.name' /kratix/input/object.yaml)"
app_namespace="$(yq eval '.metadata.namespace // "default"' /kratix/input/object.yaml)"
database_requested="$(yq eval '.status.dependencies.database.requested // false' /kratix/input/object.yaml)"
database_name="$(yq eval '.status.dependencies.database.requestName // ""' /kratix/input/object.yaml)"
tmp_dir="$(mktemp -d)"
database_ready=false
database_host=""
database_db_name=""
database_secret_name=""
wait_messages=""
append_wait_message() {
message="$1"
if [ -z "${wait_messages}" ]; then
wait_messages="${message}"
else
wait_messages="${wait_messages}; ${message}"
fi
}
if [ "${database_requested}" = "true" ]; then
if [ -z "${database_name}" ]; then
append_wait_message "database requested but no requestName recorded in status"
elif kubectl get postgresql "${database_name}" -n "${app_namespace}" -o yaml > "${tmp_dir}/postgresql.yaml" 2>/dev/null; then
database_ready_condition="$(
yq eval '.status.conditions[]? | select(.type == "WorksSucceeded") | .status' "${tmp_dir}/postgresql.yaml" \
| tail -n 1
)"
database_db_name="$(
yq eval '.status.dbName // .spec.dbName // ""' "${tmp_dir}/postgresql.yaml"
)"
if [ -z "${database_db_name}" ]; then
database_db_name="${database_name}"
fi
database_host="$(
yq eval '.status.host // .status.connectionDetails.host // ""' "${tmp_dir}/postgresql.yaml"
)"
if [ -z "${database_host}" ]; then
database_host="${app_name}-${database_name}-postgresql.default.svc.cluster.local"
fi
credentials_info="$(
yq eval '.status.connectionDetails.credentials // ""' "${tmp_dir}/postgresql.yaml"
)"
database_secret_name="$(printf '%s' "${credentials_info}" | sed -n 's/.*Secret: "\(.*\)".*/\1/p')"
database_secret_name="${database_secret_name#*/}"
if [ -z "${database_secret_name}" ]; then
database_secret_name="${app_name}.${app_name}-${database_name}-postgresql.credentials.postgresql.acid.zalan.do"
fi
if [ "${database_ready_condition}" = "True" ] && [ -n "${database_host}" ] && [ -n "${database_secret_name}" ]; then
database_ready=true
else
append_wait_message "postgresql ${database_name} is not ready or connection details have not been surfaced yet"
fi
else
append_wait_message "postgresql ${database_name} does not exist yet"
fi
fi
if [ -n "${wait_messages}" ]; then
status_message="Waiting for dependencies"
else
status_message="Dependencies ready"
fi
cat > /kratix/metadata/status.yaml <<EOF
message: ${status_message}
dependencies:
database:
requested: ${database_requested}
requestName: "${database_name}"
ready: ${database_ready}
host: "${database_host}"
dbName: "${database_db_name}"
secretName: "${database_secret_name}"
EOF
if [ -n "${wait_messages}" ]; then
cat > /kratix/metadata/workflow-control.yaml <<EOF
retryAfter: 30s
maxAttempts: 5
suspend: false
message: "${wait_messages}"
EOF
fi
We also need to build and load this image into our local environment:
docker build -t kratix-guide/app-stack-wait-for-dependencies:v0.1.0 workflows/resource/configure/wait-for-dependencies/wait-for-dependencies
kind load docker-image kratix-guide/app-stack-wait-for-dependencies:v0.1.0 --name platform
Lastly the create-runtime workflow pipeline:
In the create-runtime pipeline we will read the resolved values from status, and ONLY after our dependencies are available we will create the
Runtime request from the Runtime Promise. This pipeline also injects to the application's environment the PGHOST, DBNAME, PGUSER, and PGPASSWORD only when PostgreSQL was requested
kratix add container resource/configure/create-runtime \
--image kratix-guide/app-stack-create-runtime:v0.1.0 \
--name create-runtime
Same as before, let's rename the pipeline.sh to create-runtime to better reflect its purpose.
mv workflows/resource/configure/create-runtime/create-runtime/scripts/{pipeline.sh,create-runtime}
Make sure to update the Dockerfile to reflect the new script name. Replace the Dockerfile contents with the snippet below:
FROM alpine:3.20
RUN apk update && apk add --no-cache yq
COPY scripts/* /usr/local/bin/
ADD resources resources
RUN chmod +x /usr/local/bin/*
CMD [ "create-runtime" ]
ENTRYPOINT []
Adding the Runtime Request
Let's add the first requirement. To deploy the application via the Runtime Promise, the pipeline must output a Resource Request for that Promise. The API for the Runtime Promise looks like this:
apiVersion: marketplace.kratix.io/v1alpha1
kind: Runtime
metadata:
name: example-runtime
namespace: default
spec:
lifecycle: dev
image: syntasso/website
As you can see, the only configuration option we are currently providing in the API of our AppStack promise is the image, and the lifecycle. All the other fields should either be populated by the AppStack workflow, or left empty. We must also ensure the metadata.name we generate for this request is unique, otherwise it may clash with other resources already deployed.
Now open the create-runtime script in workflows/resource/configure/create-runtime/create-runtime/scripts and update it to:
#!/usr/bin/env sh
set -eu
app_name="$(yq eval '.metadata.name' /kratix/input/object.yaml)"
app_namespace="$(yq eval '.metadata.namespace // "default"' /kratix/input/object.yaml)"
app_image="$(yq eval '.spec.image' /kratix/input/object.yaml)"
database_requested="$(yq eval '.status.dependencies.database.requested // false' /kratix/input/object.yaml)"
database_host="$(yq eval '.status.dependencies.database.host // ""' /kratix/input/object.yaml)"
database_db_name="$(yq eval '.status.dependencies.database.dbName // ""' /kratix/input/object.yaml)"
database_secret_name="$(yq eval '.status.dependencies.database.secretName // ""' /kratix/input/object.yaml)"
if [ "${database_requested}" = "true" ]; then
if [ -z "${database_host}" ] || [ -z "${database_db_name}" ] || [ -z "${database_secret_name}" ]; then
echo "database requested but runtime inputs are incomplete" >&2
exit 1
fi
fi
cat > /kratix/output/runtime-request.yaml <<EOF
apiVersion: marketplace.kratix.io/v1alpha1
kind: Runtime
metadata:
name: ${app_name}
namespace: ${app_namespace}
labels:
kratix.io/component-of-promise-name: app-stack
kratix.io/component-of-resource-name: ${app_name}
kratix.io/component-of-resource-namespace: ${app_namespace}
spec:
lifecycle: dev
image: ${app_image}
servicePort: 8080
replicas: 1
applicationEnv:
- name: PORT
value: "8080"
EOF
if [ "${database_requested}" = "true" ]; then
cat >> /kratix/output/runtime-request.yaml <<EOF
- name: PGHOST
value: ${database_host}
- name: DBNAME
value: ${database_db_name}
- name: PGUSER
valueFrom:
secretKeyRef:
name: ${database_secret_name}
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: ${database_secret_name}
key: password
EOF
fi
We also need to build and load this image into our local environment:
docker build -t kratix-guide/app-stack-create-runtime:v0.1.0 workflows/resource/configure/create-runtime/create-runtime/
kind load docker-image kratix-guide/app-stack-create-runtime:v0.1.0 --name platform
And that's it. Workflow done!
Now there is only two things left to do in our Compound Promise:
- Set the sub-Promises as requirements for the Compound Promise
- Add the appropriate RBAC permissions
Defining the Promise Requirements
The final piece missing in our Compound Promise is the declaration of the sub-Promises it depends on. For that, you set the spec.requiredPromises field in the Compound Promise document with a list of sub-Promise names and versions.
Let's update our AppStack Promise to include the required sub-Promises:
apiVersion: platform.kratix.io/v1alpha1
kind: Promise
metadata:
creationTimestamp: null
labels:
kratix.io/promise-version: v0.1.0
name: app-stack
spec:
requiredPromises:
- name: postgresql
version: v1.0.0-beta.5
- name: runtime
version: v1.0.0
destinationSelectors: #..
api: #...
Add the appropriate RBAC permissions
The wait-for-dependencies pipeline will need permissions to be able to query if the required dependencies are ready. Add the following to your Promise:
spec:
containers:
- image: kratix-guide/app-stack-wait-for-dependencies:v0.1.0
name: wait-for-dependencies
jobOptions: {}
rbac:
permissions:
- apiGroups:
- marketplace.kratix.io
resources:
- postgresqls
verbs:
- get
- list
- apiGroups:
- ""
resourceNamespace: '*'
resources:
- secrets
verbs:
- get
- apiVersion: platform.kratix.io/v1alpha1
kind: Pipeline
metadata:
creationTimestamp: null
name: create-runtime
Installing the Promise
See the full promise.yaml
apiVersion: platform.kratix.io/v1alpha1
kind: Promise
metadata:
creationTimestamp: null
labels:
kratix.io/promise-version: v0.1.0
name: app-stack
spec:
api:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
creationTimestamp: null
name: appstacks.example.kratix.io
spec:
group: example.kratix.io
names:
kind: AppStack
plural: appstacks
singular: appstack
scope: Namespaced
versions:
- name: v1alpha1
schema:
openAPIV3Schema:
properties:
spec:
properties:
bucket:
properties:
name:
type: string
public:
type: boolean
type: object
database:
properties:
driver:
enum:
- postgresql
type: string
type: object
image:
type: string
type: object
type: object
served: true
storage: true
destinationSelectors:
- matchLabels:
environment: platform
requiredPromises:
- name: postgresql
version: v1.0.0-beta.5
- name: runtime
version: v1.0.0
workflows:
config: {}
promise: {}
resource:
configure:
- apiVersion: platform.kratix.io/v1alpha1
kind: Pipeline
metadata:
creationTimestamp: null
name: create-dependencies
spec:
containers:
- image: kratix-guide/app-stack-create-dependencies:v0.1.0
name: create-dependencies
jobOptions: {}
rbac: {}
- apiVersion: platform.kratix.io/v1alpha1
kind: Pipeline
metadata:
creationTimestamp: null
name: wait-for-dependencies
spec:
containers:
- image: kratix-guide/app-stack-wait-for-dependencies:v0.1.0
name: wait-for-dependencies
jobOptions: {}
rbac:
permissions:
- apiGroups:
- marketplace.kratix.io
resources:
- postgresqls
verbs:
- get
- list
- apiGroups:
- ""
resourceNamespace: '*'
resources:
- secrets
verbs:
- get
- apiVersion: platform.kratix.io/v1alpha1
kind: Pipeline
metadata:
creationTimestamp: null
name: create-runtime
spec:
containers:
- image: kratix-guide/app-stack-create-runtime:v0.1.0
name: create-runtime
jobOptions: {}
rbac: {}
status:
workflows: 0
workflowsFailed: 0
workflowsSucceeded: 0
If we try to install the Compound Promise now, you should get a warning:
$ kubectl --context $PLATFORM apply --filename promise.yaml
Warning: Required Promise "postgresql" at version "v1.0.0-beta.5" not installed
Warning: Required Promise "runtime" at version "v1.0.0" not installed
Warning: Promise will not be available until the above issue(s) is resolved
promise.platform.kratix.io/app-stack created
The Compound Promise itself will remain unavailable until the requirements are satisfied.
$ kubectl --context $PLATFORM get promises
NAME STATUS KIND API VERSION VERSION MESSAGE
app-stack Unavailable AppStack example.kratix.io/v1alpha1 v0.1.0
To satisfy the requiredPromises declaration, you will to, install the required promises in your Platform cluster:
kubectl --context $PLATFORM apply --filename https://raw.githubusercontent.com/syntasso/promise-postgresql/refs/tags/v1.0.0-beta.5/promise-release.yaml
kubectl --context $PLATFORM apply --filename https://raw.githubusercontent.com/syntasso/kratix-marketplace/main/runtime/promise-release.yaml
After a few seconds, you should see all the Promises available in your Platform:
$ kubectl --context $PLATFORM get promises
NAME STATUS KIND API VERSION VERSION MESSAGE
app-stack Available AppStack example.kratix.io/v1alpha1 v0.1.0
postgresql Available postgresql marketplace.kratix.io/v1alpha2 v1.0.0-beta.5 Promise configured
runtime Available Runtime marketplace.kratix.io/v1alpha1 v1.0.0 Promise configured
You may have noticed that we are applying a different type of resource: a Promise Release. This guide will not go into detail on the Promise Releases, but you can find more information on them here.
You are now ready to send requests to your Compound Promise!
Testing it all together
Now that you have everything set in the Platform, you can go ahead and deploy your applications!
For that, create a request for your AppStack Promise:
cat <<EOF | kubectl --context $PLATFORM apply -f -
apiVersion: example.kratix.io/v1alpha1
kind: AppStack
metadata:
name: app-stack
spec:
image: syntasso/sample-todo:v0.1.0 # you can use this one, or build your own
database:
driver: postgresql
EOF
Sending this request will immediately trigger the AppStack Promise Resource workflow. That workflow will create:
- a
postgresqlrequest for the PostgreSQL Promise only whenspec.database.driverispostgresql - a
Runtimerequest for the application
The container image you use for spec.image should serve HTTP on port 8080, and handle the PostgreSQL environment variables only when they are present.
That, in turn, should trigger the sub-promises workflows:
$ kubectl --context $PLATFORM get pods
NAME READY STATUS RESTARTS AGE
kratix-app-stack-app-stack-instance-configure-beec4-l2nf8 0/1 Completed 0 31s
kratix-postgresql-app-stack-db-instance-configure-785e4-hktjk 0/1 Completed 0 19s
kratix-runtime-app-stack-instance-e6313-szgrt 0/1 Completed 0 19s
In a couple of minutes, in your Worker cluster, you should see the application pod running:
$ kubectl --context $WORKER get pods
NAME READY STATUS RESTARTS AGE
app-stack-app-stack-db-postgresql-0 1/1 Running 0 56s
app-stack-db46cffb9-lbxnc 1/1 Running 0 5s
nginx-nginx-ingress-controller-758c9cb6fc-jcfd7 1/1 Running 0 87m
postgres-operator-578ff5d886-9ztf6 1/1 Running 0 87m
You can access the application via two methods:
-
Via the url http://app-stack.default.local.gd:31338
-
Or port-forward the service on the worker cluster:
$ kubectl --context $WORKER port-forward svc/app-stack 8080:8080
And navigate to http://localhost:8080/ on your browser.
