Creating a Driver
Creating a Driverβ
This page walks you through writing a new driver from scratch. The
running example is a small two-component application β call it
my-app β with a FastAPI server and a PostgreSQL database. It's
deliberately minimal so every concept on this page has a place to
land; substitute your own chart and naming as you read.
Read What is an Application Driver? and The Context first if you haven't.
Before you startβ
You need:
- A Helm chart for the application you want to deploy. The chart
should already install successfully on its own with
helm install. Styrmin doesn't write Helm charts; it operates them. - A running Styrmin instance to load and test against. The Quickstart or Local laptop guide both work.
- Familiarity with the application's lifecycle β what it stores, what needs to happen during an upgrade, how it expects to be backed up. The driver is where you encode this knowledge once so nobody has to remember it again.
The shape of a driverβ
A driver is a directory with three files:
my-driver/
βββ driver.styrmin.yml # required β the specification
βββ values.j2.yml # optional β Jinja2 template for the Helm values
βββ actions.py # required only if the spec declares actions
You'll fill them in roughly in that order: spec first to define the shape, then values to wire the chart up, then actions only if the application has lifecycle work that can't be expressed as Helm values.
Step 1: Analyse the Helm chartβ
Before you write a single line of driver, install the chart by hand once and look at what it produces:
helm install test-run <chart> -n test --dry-run --debug | less
kubectl get all -n test
You're looking for two things.
The workloads β every Deployment, StatefulSet, Job, or
CronJob the chart creates. Each of these is something Styrmin might
need to start, stop, scale, or back up independently. These are your
candidate components.
For my-app, the chart produces two workloads: the FastAPI server
(a Deployment) and a PostgreSQL primary (a StatefulSet). That's the
list of candidate components.
The HA-shaped pieces β workloads with replicas, persistent volumes, clustering, leader election, or any "this can't just be restarted in any order" property. These are the ones that need extra care:
- Stateful storage (databases, caches, queues with disk) β almost always its own component, almost always needs an application-specific backup strategy (not just a disk snapshot).
- Replicated services (an API server with N replicas) β usually one component, scale-controlled at the chart level.
- Background workers (task queues, async processors) β often a separate component if you need to stop them during a migration.
For my-app: the FastAPI server is a single stateless workload
(one component, no backup), and PostgreSQL is stateful (one component,
needs a postgres backup strategy).
Output of this step: a short list of components, with notes on what each one is and how it should be backed up.
Step 2: Write the driver specβ
Create driver.styrmin.yml. The minimum a Styrmin install will accept:
---
apiVersion: "styrmin.io/v1"
kind: ApplicationDriver
metadata:
name: my-app # must be unique across the Styrmin install
spec:
version: "1" # the driver version (you'll bump this as you iterate)
supported_versions: ">=0.1,<1.0" # semver range of the application versions this driver knows how to deploy
info:
description: Example FastAPI application with PostgreSQL
authors: [you]
license: Apache-2.0
helm:
main:
location: my-app # OCI URL or local path the server can resolve
version: 0.1.0 # the chart version
A few notes:
metadata.nameis the user-facing identifier. Once chosen, changing it later is awkward β every deployment pinned to the old name has to be re-created.supported_versionsis a PEP 440 / semver specifier describing which application versions this driver knows how to deploy. Loaders refuse to deploy an application version outside this range β a useful guard against someone selecting a version your driver hasn't been tested with.helm.main.locationis whatever the Styrmin server can pass to Helm β an OCI URL (oci://registry.example.com/charts/foo), an HTTP repository URL, or a relative path inside a baked-in chart directory.
Step 3: Declare the componentsβ
This is the core of the driver. For each candidate component from Step
1, add an entry under spec.components:
spec:
components:
server:
identifier:
label:
app.kubernetes.io/name: my-app
database:
identifier:
label:
app.kubernetes.io/name: postgresql
The identifier.label block is the contract between Styrmin and
the chart: it's how the agent finds the pods belonging to each
component. Every pod that matches all the labels in the block is
considered part of that component.
This means each component must have pods that carry a label combination Styrmin can match on. Two paths to make that happen:
-
The chart already labels things distinctly. Most well-maintained charts label pods by
app.kubernetes.io/name(or similar), and different sub-charts use different names. Themy-appexample rides on that: the embeddedpostgresqlchart labels its podsapp.kubernetes.io/name: postgresqland the main chart labels its podsapp.kubernetes.io/name: my-app. The driver just points at the existing labels. -
You add a label via chart values. Many charts expose
podLabelsorextraLabelsin theirvalues.yaml. The convention Styrmin uses internally for its own components isstyrmin/component: <name>β for charts that don't otherwise distinguish their workloads, set this invalues.j2.yml:server:
podLabels:
styrmin/component: server
worker:
podLabels:
styrmin/component: workerβ¦and then identify each component by that label.
Pick the simplest match that uniquely identifies the pods. A single, stable label is better than two; matching on values the chart already sets is better than fighting it.
Step 4: Configure backupsβ
Per component, decide how it should be backed up. The choices come
from a fixed set of backup strategies β full list and explanation
in Backups and restores.
Stateless components are typically left out entirely (no backup
block); stateful components pick the strategy that matches the
storage:
spec:
components:
server:
identifier:
label:
app.kubernetes.io/name: my-app
# no backup β stateless
database:
identifier:
label:
app.kubernetes.io/name: postgresql
backup:
enabled: true
kind: postgres # use pg_dump + Velero snapshot
database_name: my_app # strategy-specific config
If none of the built-in strategies fits, set kind: action and
implement the backup logic as a driver action (see Step 7).
Step 5: Template the Helm valuesβ
Create values.j2.yml. This is a Jinja2
template that produces the values file Helm receives. The
Context is available to it β the same context that
gets embedded in the StyrminDeployment CRD.
A short example for my-app:
fullnameOverride: styrmin-my-app
image:
repository: my-app
tag: "{{ app.version }}"
extraEnv: {{ components | component_env_vars("server") | tojson }}
ingress:
enabled: true
className: "{{ instance.config.ingress_class }}"
hosts:
- host: "{{ services['my-app'].primary_fqdn }}"
paths:
- path: /
pathType: Prefix
postgresql:
enabled: true
fullnameOverride: styrmin-postgresql
primary:
extraEnvVars: {{ components | component_env_vars("database") | tojson }}
Patterns to know:
{{ app.version }}for the application version the user picked.{{ instance.config.X }}for merged cluster/environment/deployment configuration. Use this in preference to reading the individual layers.{{ services['<name>'].primary_fqdn }}to get the auto-generated hostname for a service you declared (see next step).{{ components | component_env_vars("<name>") | tojson }}to inject the per-component env vars (declared viaenv_varsβ optional).
Container command / args overrides (optional)β
To let an operator override a component's container entrypoint or args for one
deployment β without forking your driver β emit the component_command /
component_args filters unconditionally in values.j2.yml:
command: {{ components | component_command("server") | tojson }}
args: {{ components | component_args("server") | tojson }}
Each filter returns the operator's list when set, or [] when unset. Emit
the keys unconditionally (do not wrap them in an if): the key must be
present so that clearing an override propagates (the platform applies the
HelmRelease values via JSON merge-patch, where an omitted key would leave a
prior override in place). In your chart, guard consumption with a with
block so an empty list falls through to the image/chart default:
{{- with .Values.command }}
command:
{{- toYaml . | nindent 2 }}
{{- end }}
{{- with .Values.args }}
args:
{{- toYaml . | nindent 2 }}
{{- end }}
An empty list is falsy, so {{- with }} skips it and never blanks the
container entrypoint. command and args are independent β one may be
overridden while the other falls through.
The pattern above assumes your chart guards consumption with {{- with }}. Some
upstream charts consume the value unconditionally (e.g. the Infrahub chart
does args: {{- toYaml .Valuesβ¦args }} with no guard, and exposes no command
field). For those, never hand the chart the [] marker β it would blank the
entrypoint. The Infrahub examples instead emit the override when set, or a
literal null when unset:
{%- if components | component_args("server") %}
args: {{ components | component_args("server") | tojson }}
{%- else %}
args: null
{%- endif %}
On a merge-patch update the null deletes the key so the chart's own default
applies (clearing reverts to the default); on the initial create the operator
strips the null so the key is simply absent. See driver/examples/infrahub
for the worked example and the
container-override consumption guide
for this and the default-fallback alternative.
Optionally advertise overridability (doc-only, discoverable via the API/UI)
under spec.container_overrides in driver.styrmin.yml:
spec:
container_overrides:
- component: server
container: my-app # informational: which rendered container
command: true
args: true
description: "Override the server entrypoint/args for a worker/debug mode."
The declaration does not enforce consumption: a driver that declares but does not wire the filters stores the override and silently never applies it.
See The Context for the full set of available fields.
Step 6: Declare servicesβ
If the application exposes anything over the network, declare it under
spec.services. This is what makes Styrmin auto-generate hostnames
and tells the ingress controller what to route:
spec:
services:
- name: my-app
kind: http # http | https | tcp
scope: internal # internal | external
port: 8000
component: server # which component this service belongs to
A few notes:
scope: externalopts the service into hostname generation β the application becomes reachable via the per-environment ingress.internalmeans cluster-only.- The hostname for an external service follows the pattern
<service-name>-<deployment-id>.<fqdn-suffix>, and the template can reference it via{{ services['<service-name>'].primary_fqdn }}. - See Ingress and networking for what actually happens with these declarations.
Step 7: Lifecycle actions β only when you need themβ
Most drivers don't need actions. The Helm chart, plus a per-component backup strategy, is enough to handle install / uninstall / backup / restore.
You need an action when:
- The upgrade needs sequencing. Stop the workers, run a migration, start the workers, smoke-test β that's the canonical case.
- Setup needs custom work that doesn't fit in the chart (seeding initial data, registering a webhook, warming a cache).
- Backup needs custom logic because no built-in strategy fits
(
backup.kind: action).
Actions are declared in the specβ¦
spec:
actions:
- name: upgrade
hook: upgrade # setup | upgrade
mode: core # pre | core | post
description: Upgrade my-app
location: "actions.py::upgrade"
β¦and implemented as Prefect flows in actions.py. An example
implementation for my-app:
from styrmin_backend.actions import (
GlobalContext,
execute_commands,
get_component_images,
start_components,
stop_components,
upgrade_deployment_version,
)
from prefect import flow, get_run_logger
@flow
async def upgrade(context: GlobalContext, target_version: str):
logger = get_run_logger()
await stop_components(components=["server"], context=context)
await upgrade_deployment_version(target_version=target_version, context=context)
images = await get_component_images(component="server", context=context)
image = next(img for img in images if "my-app" in img)
image_base = image.rsplit(":", 1)[0]
await execute_commands(
["my-app-upgrade"],
image=f"{image_base}:{target_version}",
context=context,
env_from_component="server",
labels_from_component="server",
)
await start_components(components=["server"], context=context)
Use the built-in primitives (stop_components, start_components,
execute_commands, upgrade_deployment_version, β¦) rather than
calling Kubernetes directly. They're idempotent, tested, and
consistent across drivers.
See Lifecycle hooks and actions for the full hook/mode model.
Step 8: Parameters β only when you need themβ
Hardcode where you can; expose a parameter when the value genuinely varies per deployment. Each parameter the driver declares shows up in the UI when a user creates a deployment.
spec:
parameters:
- name: storage_size
label: "Storage size (Gi)"
kind: integer
default: 10
Values land in {{ parameters.storage_size }} in the template.
Step 9: Load and testβ
uv run styrminctl drivers load-local-version /styrmin/drivers/my-app
(See Loading a Driver for the in-container path convention and the git-repository alternative.)
Then deploy:
uv run styrminctl deployments create my-app 0.1.0 <environment-id>
Watch the deployment in the UI. Things to check:
- All components report
Runningonce the deployment finishes. - Triggering a backup completes successfully and lands in your BSL.
- Triggering an upgrade (to another
supported_versionsvalue) runs the upgrade action and the new version comes up clean. - Deleting the deployment cleans up all resources.
Iterate by editing the driver, bumping spec.version, and re-running
load-local-version. Each load creates a new Application Driver
Version; existing deployments stay on whatever version they pinned
until you explicitly upgrade them.
Validation checklistβ
Before you call the driver done:
- Every workload in the chart maps to a declared component (or you made a conscious decision not to expose one).
- Each component's
identifier.labelmatches the pods you expect it to β verify withkubectl get pods -l <label>=<value>. - Every stateful component has a
backupblock with the right strategy. - Every externally reachable service is declared under
spec.serviceswithscope: external. - The values template uses
{{ instance.config.X }}where appropriate, not just hardcoded values. - You've successfully run an end-to-end deploy β backup β restore β upgrade cycle on a test environment.
- The driver lives in version control. Driver iteration is much easier when each version is reviewable.
Nextβ
- The Context β full reference for the fields your template and actions receive.
- Lifecycle hooks and actions β the hook / mode model and the available primitives.
- Backups and restores β full list of backup strategies, what each one does.
- Loading a Driver β how to ship your driver to production (git repository or custom image).