Skip to main content

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

  1. Encapsulate multiple Promises into a Compound Promise with waits
  2. 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:

  1. PLATFORM representing the platform cluster Kubernetes context
  2. WORKER representing 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:

platform-cluster.yaml
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

Skip the build!

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:

  1. Create a PostgreSQL instance if database.driver is set to postgresql, via the PostgreSQL Promise.
  2. Write /kratix/metadata/status.yaml with:
    • 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:

workflows/resource/configure/create-dependencies/create-dependencies/Dockerfile
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 maxAttempts and message
  • If the requested dependency is ready:
    • write /kratix/metadata/status.yaml with the resolved values needed by the Runtime request.

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:

workflows/resource/configure/wait-for-dependencies/wait-for-dependencies/Dockerfile
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:

workflows/resource/configure/create-runtime/create-runtime/Dockerfile
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
tip

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 postgresql request for the PostgreSQL Promise only when spec.database.driver is postgresql
  • a Runtime request 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:

  1. Via the url http://app-stack.default.local.gd:31338

  2. 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.