Kit Development Guide
This guide covers how to build a kit for easy-db-lab — from the kit.yaml structure
through lifecycle phases, metrics collection, hooks, and Grafana dashboard provisioning.
What is a Kit?
A kit is a self-contained package of configuration and scripts that installs, starts, stops, and optionally backs up a piece of software on the cluster. Examples: ClickHouse, Presto, OpenSearch.
Each kit lives under src/main/resources/com/rustyrazorblade/easydblab/kits/<name>/.
After easy-db-lab kit install <name> runs, the kit directory is copied to the cluster's working
directory. The <name> start, <name> stop, etc. subcommands then drive it.
Directory Layout
Everything for a kit lives in src/main/resources/com/rustyrazorblade/easydblab/kits/<name>/:
kit.yaml— required: the kit definitionbin/— optional: shell scripts (start.sh.template,stop.sh.template, ...) for phases that have no typed steps. Everybin/*.shbecomes a command, so put only phase scripts here.lib/,pod/and other directories — optional: shared script code, scripts that run inside a pod, query files. They are copied into the kit directory and never become commands (clickhouse-benchmarkkeeps its sourced functions inlib/and its pod-side scripts inpod/).dashboards/— optional: Grafana dashboard JSON files<name>.yaml.template— optional: K8s manifest templates applied bymanifeststepsMETRICS.mdandmetrics-catalog.json— recommended for any kit that exposes metrics
kit.yaml Reference
name: myworkload
type: db # db | app: the node pool the kit needs; install fails if it has no nodes
description: Short description shown in help text
version: "1.0.0"
collision-check: false # true = refuse a second install and a start while running; or a map per phase (see Collision check)
metrics: # a list; see Metrics section
- type: scrape # scrape | java-agent | helm-native
port: 9090
runtime:
type: helm # see Runtime section
release: myworkload
namespace: default
endpoints:
- name: "HTTP UI"
node-type: app # "app" or "db"
port: 8080
type: http # http | https | jdbc | native | cql | postgresql | mysql | kafka
scheme: "" # optional: used for JDBC URLs (jdbc type only)
path: "" # optional: appended to URL (http/https/jdbc only)
database: "" # optional: logical database name (postgresql and mysql types)
args:
- flag: --workers
variable: WORKERS
description: "Number of workers"
type: int # string | int | float | boolean | kit-ref | extension
capability: sql # optional: for kit-ref, a capability the target must declare
endpoints: [native] # optional: for kit-ref, endpoint types the target must declare
required: false
default: "${APP_NODE_COUNT}"
hooks:
post-workload-start:
script: bin/update-catalogs.sh
workloads: [] # optional: only fire when these kits start
post-workload-stop:
script: bin/update-catalogs.sh
install: [] # steps to run on `easy-db-lab kit install <name>`
start: [] # steps to run on `easy-db-lab <name> start`
stop: [] # steps to run on `easy-db-lab <name> stop`
uninstall: [] # steps to run on `easy-db-lab <name> uninstall`
backup: [] # steps to run on `easy-db-lab <name> backup <backup-name>`
restore: [] # steps to run on `easy-db-lab <name> restore <backup-name>`
Lifecycle Phases
| Phase | Trigger | What happens after success |
|---|---|---|
install | easy-db-lab kit install <name> | Kit directory written to working dir |
start | easy-db-lab <name> start | Metrics registered, dashboards installed, hooks fired |
stop | easy-db-lab <name> stop | Waits for the runtime's pods to go, metrics deregistered, kit dropped from runningKits, hooks fired |
uninstall | easy-db-lab <name> uninstall or kit uninstall <name> | Kit directory deleted from working dir. A kit still running has its stop phase run first, stop wait included; if that fails, the uninstall fails without running its steps. After the uninstall steps the kit is released as stop releases it (metrics deregistered, dropped from runningKits, hooks fired) |
backup | easy-db-lab <name> backup <name> | BACKUP_NAME env var set to first argument |
restore | easy-db-lab <name> restore <name> | BACKUP_NAME env var set to first argument |
If no typed steps are defined for a phase and a matching script exists in bin/ (e.g.
bin/start.sh), the script is executed instead.
Step Types
All phases use the same set of typed steps.
helm-repo
Adds a Helm chart repository.
- type: helm-repo
name: altinity
url: https://docs.altinity.com/clickhouse-operator/
helm
Installs or upgrades a Helm chart.
- type: helm
chart: altinity/altinity-clickhouse-operator
release: clickhouse-operator
namespace: kube-system
version: "1.2.3" # optional: pin chart version
values: # optional: inline values
replicaCount: "3"
values-file: values.yaml # optional: path relative to kit dir
helm-uninstall
Uninstalls a Helm release.
- type: helm-uninstall
release: clickhouse-operator
namespace: kube-system
When one operator serves several kit instances, keep-while-any names a resource type and keeps
the release while any object of that type is left in any namespace, reporting what still uses it
(Kit.HelmReleaseKept). Plain postgres and every postgres-<extension> share the CNPG operator:
- type: helm-uninstall
release: cnpg-operator
namespace: cnpg-system
keep-while-any: clusters.postgresql.cnpg.io
manifest
Applies a K8s manifest template. The template file must exist in the kit's resource directory. Template variables (see below) are substituted before applying.
- type: manifest
template: clickhouseinstallation.yaml
manifest-url
Fetches and applies a manifest from a URL.
- type: manifest-url
url: https://example.com/operator.yaml
kustomize
Applies a kustomize configuration from a URL.
- type: kustomize
url: https://github.com/example/repo/config/default
namespace
Creates a Kubernetes namespace (no-op if it already exists).
- type: namespace
name: monitoring
wait
Waits for a K8s resource to reach a condition.
- type: wait
kind: Deployment
name: my-operator
namespace: kube-system
condition: Available # default: Available
timeout: 300s # default: 300s
delete
Deletes K8s resources, either one object by name or every object a label selector matches.
Both forms run kubectl on the control node. A step that mixes the two forms, or has neither, is
rejected when kit.yaml loads.
By name — kind and name are required:
- type: delete
kind: ClickHouseInstallation
name: clickhouse
namespace: default # default: default
ignore-not-found: true # default: true
By label — selector and kinds are required:
- type: delete
kinds: [deployment, replicaset, pod, service, configmap]
selector: easydblab/kit=memcached
namespace: default # default: default
Every object of the listed kinds in namespace that selector matches is deleted. When nothing
matches, the step succeeds and prints nothing. A failed cluster query fails the step. selector,
kinds and namespace accept ${VAR} interpolation. Use this form in stop and uninstall for
kits that label every object they create, instead of a shell step.
platform-pvs
Creates persistent volumes on cluster nodes using the platform substrate.
- type: platform-pvs
node-type: db # default: db
count: 3 # optional: defaults to node count
storage-size: 10Ti # optional: PV capacity; defaults to the STORAGE_SIZE variable
if-set: EXTSTORE_SIZE # optional: skip the step unless this kit variable is non-empty
if-set makes the volume optional: the memcached kit creates its extstore PV only when
--extstore-size is set. Pair it with platform-pvs-delete in uninstall, which is a no-op when
no PV was created.
Every kit's PVs share one storage class, and each PV is labelled app.kubernetes.io/name=<kit>.
Every claim the kit creates — its own, a StatefulSet's, or one an operator generates — must select
that label (selector.matchLabels, or Strimzi's selector) or name the PV in volumeName.
Otherwise it can bind another kit's free PV.
configmap
Creates or updates a K8s ConfigMap.
- type: configmap
name: my-config
namespace: default
data:
key: value
label
Applies labels to cluster nodes.
- type: label
node-type: db
labels:
kit: presto
exec
Runs a command inside a running pod.
- type: exec
pod: my-pod-name
namespace: default
command: ["clickhouse-client", "--query", "SELECT 1"]
shell
Runs an inline shell script. The script runs locally (not on the remote node) with cluster variables injected as environment variables.
- type: shell
script: |
kubectl wait --for=condition=Ready pods \
-l app=myworkload \
--timeout=300s
How local scripts reach the cluster
Shell steps in install: and in phases, phase scripts in bin/, and hook scripts all run on your
machine with the same two settings:
KUBECONFIGis the absolute path of the workspacekubeconfig. The process does not start if that file does not exist.<workspace>/binis first onPATH. easy-db-lab writes a wrapper there for each ofkubectl,helm,cilium,curl,skopeo,k9s,ssh,scp,sftpandrsyncbefore the process starts. Thessh,scp,sftpandrsyncwrappers use the workspacesshConfig, so a step can runssh db0 ...by the node's alias; they never use the tunnel.
On a SOCKS cluster (no Tailscale), each of the other wrappers sends its call through the SOCKS5 tunnel that easy-db-lab keeps open, and the tool reaches the private cluster addresses. On a Tailscale cluster the wrappers run the tool unchanged, and it connects directly. The CLI starts or restarts the tunnel before the first step runs, so a kit never has to.
The kubectl, helm, cilium and k9s wrappers always set KUBECONFIG to the workspace kubeconfig for their call, in place of any inherited value, on SOCKS and Tailscale clusters. A bare <workspace>/bin/kubectl therefore works with no setup, from a step or from your own shell. If the workspace has no kubeconfig file, the wrapper names the missing file and exits 1. The curl and skopeo wrappers leave KUBECONFIG unchanged.
Indirect calls go through the wrappers too, because they look the tool up on PATH: timeout 60 kubectl ..., echo ns | xargs kubectl get, sh -c 'helm list -A', and a script in the kit's own
bin/ that calls kubectl.
Some things are not wrapped:
- Tools other than those six,
awsincluded, get no proxy settings and connect directly. - A call that bypasses
PATHis not wrapped: an absolute path such as/usr/local/bin/kubectl,env -i kubectl, or a script that sets its ownPATH=/usr/bin:/bin. Call the tool by its name and keep the inheritedPATH. - A wrapped tool sends all of its traffic through the tunnel, public URLs included.
helm repo addof a public chart repository works, but it is slower than a direct connection.
If a wrapper says that no SOCKS tunnel is recorded, run easy-db-lab socks start in the workspace.
Environment Variables
All scripts and shell steps receive the following environment variables:
| Variable | Description |
|---|---|
CLUSTER_NAME | Name of the cluster |
KUBECONFIG | Absolute path to the workspace kubeconfig file |
PATH | <workspace>/bin (the tool wrappers) followed by the inherited PATH |
CONTROL_HOST | Public IP of the control node |
CONTROL_HOST_PUBLIC | Public IP of the control node |
CONTROL_HOST_PRIVATE | Private IP of the control node |
DB_NODE_COUNT | Number of database nodes |
APP_NODE_COUNT | Number of app/stress nodes |
DB_NODE_IPS | Comma-separated private IPs of database nodes |
APP_NODE_IPS | Comma-separated private IPs of app nodes |
BUCKET_NAME | S3 data bucket name |
ACCOUNT_BUCKET | S3 account-level bucket name |
REGION | AWS region |
KIT_NAME | Name of this kit |
STORAGE_SIZE | Storage size (from --size arg, if used) |
STORAGE_CLASS_WFC | K8s storage class name with WaitForFirstConsumer binding |
RUNNING_KITS | Comma-separated names of currently running kits |
EASY_DB_LAB_EXEC | Path to the easy-db-lab binary |
BACKUP_NAME | First positional argument (backup and restore phases only) |
EDL_KIT_LIB | Absolute path of the shared kit shell library, <workspace>/bin/edl-kit-lib.sh (see Shared shell library) |
Args declared in kit.yaml under args: are also injected using their variable name. For
example, --workers with variable: WORKERS becomes $WORKERS. An arg the user leaves out that
has no default is set to an empty string, never the string null, and it replaces a variable of
the same name that the operator's shell exports; test it with [ -n "${WORKERS}" ]. A boolean
arg takes an optional value: a bare --flag means true, and --flag false or --flag=false
turns off a flag whose default is true (--help shows --flag[=<value>]).
Addressing per-node services (e.g. the Cassandra Sidecar)
Some cluster services run as a hostNetwork DaemonSet — one instance per db node, addressable
at <db-node-private-ip>:<port> with no cluster-wide load-balanced Service (the Cassandra Sidecar
on port 9043 is the canonical example). Each instance is node-local: a request only affects the
node it fronts. A kit that needs to reach every such instance (for example, creating a per-node
snapshot before a distributed read) must fan the call out across all nodes rather than wiring a
single node's URI.
Use DB_NODE_IPS — the comma-separated list of all db-node private IPs — as the enumeration source:
# Create a Sidecar snapshot on every db node, not just one
IFS=',' read -ra DB_IPS <<< "$DB_NODE_IPS"
for ip in "${DB_IPS[@]}"; do
curl -sf -XPUT "http://${ip}:9043/api/v1/keyspaces/${KS}/tables/${TBL}/snapshots/${SNAP}"
done
Wiring only one node's address (e.g. db0) leaves the other nodes without the resource, so any work
that lands on db1/db2 fails. DB_NODE_IPS is part of the stable variable contract above, so this
pattern does not depend on topology discovery from within the kit.
Default values in kit.yaml can reference any of the variables above using ${VAR} syntax:
default: "${APP_NODE_COUNT}"
A single arg cannot produce two derived forms — there is no transform syntax. If a kit needs
the same value in different shapes (e.g. Flink uses the image tag 1.20 and the flinkVersion
enum v1_20), hardcode the derived form in the manifest template and document the lockstep
coupling with the arg.
Metrics
The metrics field is a list of entries, each with a type, telling easy-db-lab how the kit's
metrics reach the collector. When start succeeds, scrape entries are registered. When stop
succeeds, they are deregistered.
scrape — Prometheus endpoint
The kit exposes a Prometheus endpoint and the OTel collector DaemonSet scrapes it.
metrics:
- type: scrape
port: 9090 # required
path: /metrics # optional, default: /metrics
job: myworkload # optional, default: the kit name; unique within the kit
pod-selector: "app=myworkload" # optional: scrape the matching pods directly (see below)
username: "" # optional: basic-auth user
Without a pod-selector, every collector scrapes localhost:<port>, so the port must be reachable
on the node: a NodePort, or a hostPort on the pod. With a pod-selector, the collector finds the
matching pods through Kubernetes pod discovery and each node's collector scrapes only the pods on
its own node, on the pod IP and port (the container port). Every pod gets its own instance
label, and no metrics NodePort is needed. See kits/memcached/kit.yaml.
Use a pod-selector. A job without one runs on every collector in the DaemonSet, whatever
node the workload is on: a NodePort answers on every node, so a single-instance kit reports one
duplicate series per node, each with a different collector's hostname as instance, and a hostPort
answers only on its own node, so every other node reports the job down. Every built-in kit scrapes
by pod discovery, and NodePortKitScrapeTest fails if one declares a static scrape. A kit that
runs several instances side by side (postgres and postgres-<extension>) names its own with
${KIT_NAME} in the selector, which is filled in with the instance name at start.
Before adding any reporter plumbing, check whether the workload's image already ships the
metrics reporter — a pre-staged plugin directory, a built-in endpoint, or a bundled jar
already on the classpath. Many JVM images do (e.g. the official Flink image pre-stages the
Prometheus reporter at /opt/flink/plugins/metrics-prometheus/; you only set the reporter
config — no plumbing). Only add an initContainer or volume to stage a reporter jar if it is
genuinely absent.
Never mount an emptyDir over a directory the image already populates — it hides what the
image staged there. (We hit a crash copying the Flink reporter jar from an assumed path that
did not exist, while the emptyDir overlay masked the real pre-staged plugin dir.)
Registration creates one K8s ConfigMap per scrape entry, named easydblab-metrics-<kit>-<job> and
labelled easydblab.com/workload-metrics=true. OtelSyncService reads these ConfigMaps and
regenerates the OTel collector config with a scrape job for each. Scraped metrics receive the
entry's job and cluster=<cluster-name> labels automatically.
A hostPort scrape assumes one metrics-exposing pod per node per kit. A kit that exposes
metrics from multiple pods (e.g. Flink serves :9249 on the JobManager and every TaskManager)
will collide on the hostPort if two land on the same node. Prefer a pod-selector; otherwise
spread them with podAntiAffinity and keep replicas below the node count.
java-agent — OpenTelemetry Java Agent
For JVM kits. The OTel Java agent JAR at /usr/local/otel/opentelemetry-javaagent.jar
is attached to the JVM process.
metrics:
- type: java-agent
service-name: myworkload
The agent pushes OTLP to the collector on the pod's own node; nothing in the collector config
changes. Mount /usr/local/otel from the host read-only, load the jar through the workload's
JVM options, and set OTEL_SERVICE_NAME (it becomes job), HOST_IP from status.hostIP, and
OTEL_EXPORTER_OTLP_ENDPOINT=http://$(HOST_IP):4318. See kits/neo4j/statefulset.yaml.template.
Never write a double underscore in a .template file. __NAME__ is the install-time
placeholder syntax, so a literal __ (e.g. Neo4j's NEO4J_server_bolt_advertised__address)
swallows the text up to the next __. Put such a name in kit.yaml instead — the Neo4j kit's
start step writes it as a ConfigMap key and the pod loads it with envFrom.
helm-native — Built-in telemetry
The kit ships its own metrics pipeline via Helm values. No OTel config change is needed.
metrics:
- type: helm-native
Documenting Metrics
Every kit that exposes metrics should include a METRICS.md file listing the available
metrics, their labels, and usage notes. This is the reference for anyone building dashboards.
See kits/presto/METRICS.md for an example.
Exporting the Metrics Catalog
metrics-catalog.json lists every metric a running kit sends to Mimir under
job="<kit>". It is exported from a live cluster by bin/export-workload-metrics, and the
committed copy is what METRICS.md and the kit's dashboards are built from.
The catalog is compact: series holds one entry per distinct metric name, and each entry's
labels maps a label key to the sorted distinct values seen for it, at most 20 per key
(MAX_LABEL_VALUES in the script). Per-pod identity labels (k8s_pod_uid, k8s_pod_name,
instance, service_instance_id) are dropped, so the file does not grow with the number of
pods.
Label keys present on every series of the export (the collector's infrastructure labels such as
cluster, host_name, job and k8s_*) are listed once, in the top-level common_labels map,
in the same key → values form and with the same cap. They are left out of every entry, so an
entry's labels holds only the labels specific to that metric, and is {} when it has none:
{
"workload": "clickhouse",
"exported_at": "2026-09-23T12:00:00Z",
"common_labels": {
"host_name": ["db0", "db1", "db2"],
"job": ["clickhouse"]
},
"series": [
{
"name": "ClickHouseMetrics_Query",
"labels": { "service_name": ["clickhouse"] }
},
{
"name": "target_info",
"labels": {}
}
]
}
A series' full label set is its entry's labels plus common_labels.
The transform lives in bin/metrics-catalog.jq, so a full per-series export (the older format,
one entry per series with a single value per label) can be converted offline with the same code
the script uses:
jq -L bin --argjson cap 20 \
'include "metrics-catalog"; del(.series) + (.series | compact_catalog($cap))' \
old-catalog.json > metrics-catalog.json
Only series with a sample in the last five minutes are exported, so series left behind by pods that have since been replaced do not end up in the catalog. Export while the kit is running and being scraped.
The script sources env.sh, so run it from the cluster workspace (the directory holding
env.sh, state.json and kubeconfig), calling it by its path in your checkout:
cd <cluster-workspace>
<checkout>/bin/export-workload-metrics <kit>
It writes <cluster-workspace>/<kit>/metrics-catalog.json, not the kit's resource directory.
That <kit>/ directory is the kit's working copy, and <kit> uninstall deletes it, so copy the
file into the source tree before uninstalling:
cp <cluster-workspace>/<kit>/metrics-catalog.json \
<checkout>/src/main/resources/com/rustyrazorblade/easydblab/kits/<kit>/metrics-catalog.json
Hooks
Hooks let one kit react when another kit starts or stops. The hook script runs in the context of the declaring kit, not the triggering one.
hooks:
post-workload-start:
script: bin/update-catalogs.sh
workloads: [cassandra] # optional: only fire when cassandra starts
post-workload-stop:
script: bin/update-catalogs.sh
When easy-db-lab cassandra start completes, easy-db-lab scans every installed kit
directory, finds those with a matching post-workload-start hook, and fires them.
If workloads is empty or omitted, the hook fires for any kit start/stop. Hooks retry up
to 3 times with exponential backoff (1s, 2s, 4s) on failure.
Use case: Presto registers its Cassandra catalog after Cassandra starts. Its
post-workload-start hook runs bin/update-catalogs.sh which re-registers catalogs for all
currently running data sources.
Grafana Dashboards
After a successful start, easy-db-lab installs dashboards into Grafana via the HTTP API.
Auto-discovery (default): any .json files in dashboards/ are installed automatically.
Files are installed in alphabetical order into a Grafana folder named after the kit.
Explicit list (optional): declare dashboard paths in kit.yaml to control selection or
order:
dashboards:
- path: dashboards/overview.json
- path: dashboards/queries.json
name: Query Details
A kit with an extension arg (postgres) runs as several instances side by side (postgres,
postgres-duckdb). A dashboard with extension: is installed only by the instance created with
that extension; one without is installed by every instance. Grafana uids are global, so an
instance other than the kit's own installs each dashboard under the uid suffixed with its
extension (postgres-overview-duckdb), and links between the dashboards it installs point at
its own copies; it never moves another instance's dashboard into its folder. A link to another
extension's dashboard points at the uid that extension's instance installs it under
(/d/postgres-duckdb becomes /d/postgres-duckdb-duckdb), in every instance.
dashboards:
- path: dashboards/postgres.json
- path: dashboards/duckdb.json
extension: duckdb
Dashboard JSON files should:
- Use
"uid": "<kit>-kit"to make re-installs idempotent - Declare the
clustervariable:label_values(up, cluster)on${metrics_datasource}, multi-select, with "All" andallValue.+. Leave itscurrentempty. - Filter every metrics and logs selector by
cluster=~"$cluster", including the queries of other variables such as an instance list:label_values(cnpg_collector_up{cluster=~"$cluster"}, instance). Clusters of one tenant share the store, so a selector without the filter mixes every cluster that ran at the same time. In a binary expression, each side needs its own filter. - Keep
clusterin every aggregation (sum by (cluster, instance),sum by (cluster) (...)), and show it in every legend by its short name: wrap the query inlabel_replace(<expr>, "cluster_name", "$1", "cluster", "(.+-[0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")and write the legend as{{cluster_name}} {{instance}}. The short name keeps the first 8 characters of the id (test-1a2b3c4d), so two clusters that share a name still differ. Two selected clusters then draw as separate series, each named. A field override matches the prefixed legend (byRegexp^(.* )?reads$, notbyNamereads). A table keeps its legends and shows the cluster as a column: itsorganizehidesclusterand renamescluster_nameto "Cluster". - Declare a datasource picker for each signal they use:
metrics_datasource("type": "datasource", "query": "prometheus", label "Metrics"), andlogs_datasource(loki) ortraces_datasource(tempo) when they use logs or traces. Leave each picker'scurrentempty. - Name every datasource through its picker, keeping the
type:{ "type": "prometheus", "uid": "${metrics_datasource}" }. Never namemimir,lokiortempodirectly, not even inside an Explore link;pyroscopehas no picker and stays as it is. - Pass each picker and
clusterthe dashboard declares on every/d/link, as${metrics_datasource:queryparam}(or an explicitvar-<name>=), and no${name:queryparam}for a variable it does not declare. - Include
"tags": ["<kit>", "kit"]
DashboardDatasourceVariablesTest checks the picker and link rules on every kit dashboard, ClusterFilterTest checks the cluster variable and filter, and SeriesClusterTest checks the aggregations and legends. dashboards/CLAUDE.md in the repository has the details.
On start, the dashboards go through the same install-time pass as the core dashboards (DashboardDefaults): each picker selects the cluster's own tenant and cluster defaults to the current cluster, so a dashboard opens on the cluster with no selection by hand. Dashboards are installed with overwrite: true, so re-running start never duplicates them.
Collision check
collision-check is a boolean or a map of phase to boolean. true guards both install and
start; false (the default) guards neither. The map guards only the phases set to true:
collision-check:
start: true # refuse start while running
install: false # a second install overwrites the scaffold without --force
install and start are the only phases with a collision check; any other key is rejected when
the kit is loaded. Each guarded phase fails with an error event and exits non-zero:
kit installrefuses when the kit's scaffold directory already exists and is not empty. The event isInstall.CollisionDetected. Pass--forceto overwrite the scaffold.<kit> startrefuses when the workload the kit'sruntimeblock declares is already in the cluster: pods matching the runtimeselectorin its namespace, or, for ahelmruntime, the helm release and then any pod labelledapp.kubernetes.io/instance=<release>in the runtime's namespace. Pods that are still terminating count too, so a pod left cleaning up by a manualkubectl deleteblocksstartuntil it is gone. The event isKit.CollisionDetected, naming the objects it found, and no start step runs. Run<kit> stopfirst.
<kit> stop is not a collision check, and it applies to every kit that declares a runtime,
guarded or not. Once its steps succeed, it waits until none of the runtime's pods is left,
terminating pods included. For a helm runtime that means the pods labelled
app.kubernetes.io/instance=<release>; the release itself does not count, because a kit may stop
by scaling its release to zero (Presto, Trino) and keep it. Deleting a StatefulSet, Deployment or
operator resource, or scaling a Deployment to zero, returns before its pods have terminated, so
this wait is what makes stop return only once the workload is gone, and stop followed by
start work. It gives up after 5 minutes with a Kit.StopIncomplete error event naming what is
left. A failed cluster query during the wait is retried; if the last look still fails, stop
exits non-zero with a Kit.StopUnverified error event carrying the cause. A kit with no runtime
block does not wait.
<kit> uninstall waits the same way after its steps succeed — a helm uninstall (Presto, Trino)
also returns while the release's pods are still terminating — and reports a pod that outlives the
wait with the same events, naming the uninstall phase; the kit directory is then kept.
The runtime must therefore name what start creates and stop removes. A runtime pointing at
something the install phase creates, such as an operator's helm release, would refuse every
start after a successful install. A kit with no runtime block is looked up by the pod label
app.kubernetes.io/name=<kit>.
Runtime
The runtime field tells easy-db-lab how to find running pods for status checks, log tailing, and
the start collision check.
runtime:
type: helm # helm | deployment | statefulset | pods
release: presto # for helm: the Helm release name
namespace: default
selector: "app=presto" # for pods: label selector
name: presto # for deployment/statefulset: resource name
Profiling
Every kit running on the cluster is automatically profiled at the system level by the Grafana Alloy eBPF DaemonSet — no per-kit setup required. This covers all processes including non-JVM ones like ClickHouse.
JVM kits get deeper profiling via the Pyroscope Java agent
(/usr/local/pyroscope/pyroscope.jar, pre-installed on every node by packer). This enables
method-level CPU, allocation, and lock contention profiles — much richer than eBPF.
Wiring up the Java agent for a K8s kit
The agent JAR lives on the host at /usr/local/pyroscope. Mount it into each JVM container via a
hostPath volume, then inject JAVA_TOOL_OPTIONS via a kubectl patch in the start phase.
CONTROL_HOST_PRIVATE and CLUSTER_NAME are available as environment variables at runtime.
PYROSCOPE_OPTS="-javaagent:/usr/local/pyroscope/pyroscope.jar \
-Dpyroscope.application.name=<kit> \
-Dpyroscope.server.address=http://${CONTROL_HOST_PRIVATE}:4040 \
-Dpyroscope.format=jfr \
-Dpyroscope.profiler.event=cpu \
-Dpyroscope.profiler.alloc=512k \
-Dpyroscope.profiler.lock=10ms"
PATCH=$(jq -n \
--arg opts "${PYROSCOPE_OPTS} -Dpyroscope.labels=cluster=${CLUSTER_NAME},component=<component>" \
'{spec:{template:{spec:{
volumes:[{name:"pyroscope-agent",hostPath:{path:"/usr/local/pyroscope"}}],
containers:[{name:"<container-name>",env:[{name:"JAVA_TOOL_OPTIONS",value:$opts}],
volumeMounts:[{name:"pyroscope-agent",mountPath:"/usr/local/pyroscope",readOnly:true}]}]
}}}}')
kubectl patch deployment <deployment-name> --namespace default --type=strategic -p="$PATCH"
Use component=coordinator, component=worker, etc. in the pyroscope.labels to distinguish
multiple JVM processes belonging to the same kit. Profiles appear in Grafana's Pyroscope
datasource under service_name=<kit>.
Operator-managed (CRD) kits
For kits whose pods are managed by an operator (the Flink operator, CNPG, etc.), a kubectl patch does not work — the operator reconciles its pods and reverts the patch. Put the JVM
options in the operator's own resource instead (e.g. a FlinkDeployment's flinkConfiguration
or podTemplate, or the CR's pod template). For these kits the per-kit Java agent is optional
anyway — system-level eBPF profiling already covers them, so it can be deferred.
See docs/user-guide/profiling.md for how to access profiles in Grafana, profile types, and
the full observability data flow.
Shared shell library
A kit script that runs pods sources the shared kit shell library instead of writing its own pod
placement, waits and gauge push. The CLI packages it as configuration/edl-kit-lib.sh, writes it to
<workspace>/bin/edl-kit-lib.sh, and sets EDL_KIT_LIB to its absolute path for every kit process
(phase scripts, shell steps and hooks). Source it at the top of the script:
#!/usr/bin/env bash
set -euo pipefail
. "${EDL_KIT_LIB:?run this through easy-db-lab, which sets EDL_KIT_LIB}"
Sourcing it changes no set option, trap or working directory, and defines only edl_ functions
and EDL_ variables. The functions read KIT_NAME, KUBECONFIG and CONTROL_HOST_PRIVATE from
the environment. A function that fails prints why to stderr and exits the script.
| Function | What it does |
|---|---|
edl_fail <message> | Prints the message to stderr and exits 1 |
edl_labels <role> | Prints easydblab/kit=${KIT_NAME},easydblab/role=<role> |
edl_resource_name <role> | Prints ${KIT_NAME}-<role>-$(date +%s), a unique pod or ConfigMap name |
edl_require_app_node <kit> | Fails with <kit> needs an app node (type=app); this cluster has none when no node is labelled type=app |
edl_pod_overrides [<configmap> <mount-path> <container>] | Prints the JSON for kubectl run --overrides=<json> --override-type=strategic: nodeSelector: {type: app}, and a mount of the ConfigMap when one is given. kubectl run names the container after the pod |
edl_create_configmap <name> <role> <key>=<path>... | Creates a labelled ConfigMap from files in one call; fails on a missing or empty file, or more than 1,000,000 bytes in all |
edl_active_pods <role> | Prints this instance's pods of that role that have not ended |
edl_wait_for_pod_running <pod> | Returns once the pod leaves Pending (Running, Succeeded or Failed); fails at once on an image pull error with the reason and the pod's events, and after 10 minutes otherwise, and deletes the pod first so it does not block the next run |
edl_follow_pod_logs <pod> <handler> | Streams the pod's log, calling the handler function with each line, and resumes a cut stream while the pod runs |
edl_wait_for_pod_exit <pod> | Waits, with no limit, for the pod to end and prints its exit code |
edl_push_gauges <service> <name> <value>... | Pushes OTLP gauges labelled kit=${KIT_NAME} to the collector; a non-numeric value is sent as 0; a failed push never fails the script and prints one warning per script |
Every wait fails with pod <name> was deleted when the pod is gone, for example after a stop in
another terminal. A typical run:
edl_require_app_node my-bench
POD="$(edl_resource_name bench-run)"
kubectl run "${POD}" --image="${IMAGE}" --restart=Never \
--labels="$(edl_labels bench-run)" \
--overrides="$(edl_pod_overrides)" --override-type=strategic \
--command -- my-bench --threads "${THREADS}"
edl_wait_for_pod_running "${POD}"
edl_follow_pod_logs "${POD}" handle_line
EXIT_CODE="$(edl_wait_for_pod_exit "${POD}")"
[ "${EXIT_CODE}" = 0 ] || edl_fail "The run failed with exit code ${EXIT_CODE}"
Kit scripts run on the operator's machine, where #!/usr/bin/env bash can be the macOS bash 3.2.
The library, and every script that sources it, follows these rules:
- No associative arrays,
mapfile,${v,,}orwait -n. - Never expand an array that can be empty as
"${arr[@]}"underset -u: bash before 4.4 fails withunbound variable. Add to an array only when there is at least one element to pass, or build the list another way. - Assign a command substitution on its own line (
X="$(f)"), never withlocal X="$(f)"or inside aniftest, so a failure inside it still ends the script underset -e.
The library tests (./gradlew testKitShellLib) and the kit script tests also run under /bin/bash
when it is bash 3.x.
The library is written once into each workspace and never rewritten, so a workspace keeps the
library it was first given. When you edit the library, use a fresh workspace, or delete
<workspace>/bin/edl-kit-lib.sh, before you try the change; the next kit process writes the new
copy. Report parsers, argument checks and SQL belong in the kit's own lib/, not in the library.
Bench Kits — cross-kit targeting
A bench kit benchmarks a running database kit. It declares a kit-ref arg that the user
populates with --target <kit-name> at install time. The framework then reads the target
kit's declared endpoints and injects them as TARGET_* environment variables into every phase
script.
Declaring a kit-ref arg
args:
- flag: --target
variable: TARGET
type: kit-ref
capability: sql # optional: a capability the target must declare
endpoints: [native, http] # optional: endpoint types the target must declare
description: "Name of the running database kit to benchmark"
required: true
type: kit-ref tells easy-db-lab three things:
- The installed kit directory is named
<kit>-<target>instead of<kit>, allowing multiple simultaneous instances (e.g.sysbench-clickhouseandsysbench-tidb). - At install, the target must already be installed and must provide what the arg asks for.
kit installrefuses, before it writes anything, a target that lacks thecapabilityor any type inendpoints, and names every missing type in one message (for exampleTarget kit 'tidb' does not have the 'native' endpoint required by clickhouse-benchmark). An unknown type inendpointsfails whenkit.yamlis parsed. With neither field, the target is not checked. - In every phase,
KitEndpointResolverreads the target kit'skit.yamlendpoints and injects them asTARGET_*environment variables.
TARGET_* injection rules
The variables injected depend on what endpoints the target kit declares:
| Endpoint type | Variables injected |
|---|---|
jdbc | TARGET_JDBC_URL, TARGET_JDBC_USER, TARGET_JDBC_DRIVER |
postgresql | TARGET_PG_HOST, TARGET_PG_PORT, TARGET_PG_USER, TARGET_PG_DATABASE |
mysql | TARGET_MYSQL_HOST, TARGET_MYSQL_PORT, TARGET_MYSQL_USER, TARGET_MYSQL_DATABASE |
native | TARGET_NATIVE_HOST, TARGET_NATIVE_PORT, TARGET_NATIVE_USER, TARGET_NATIVE_DATABASE |
http | TARGET_HTTP_URL |
kafka | TARGET_KAFKA_BOOTSTRAP |
https and cql endpoints inject nothing. Every *_USER variable comes from the target's sql
capability user, and is empty when the target declares no sql capability.
If the target kit directory does not exist or its kit.yaml is unreadable, no TARGET_*
variables are injected and no error is raised (fail-safe).
Wiring a TARGET_* endpoint into an application pod
TARGET_* variables are available in the kit's shell scripts — not inside running pods.
To make the address available to an application, write a ConfigMap from the start script
and reference it in your deployment.
In bin/start.sh.template:
# Write the target endpoint into a ConfigMap the application pod reads
kubectl create configmap my-app-config \
--from-literal=KAFKA_BOOTSTRAP_SERVERS="$TARGET_KAFKA_BOOTSTRAP" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f my-app-deployment.yaml
In my-app-deployment.yaml:
spec:
containers:
- name: my-app
envFrom:
- configMapRef:
name: my-app-config
The application sees KAFKA_BOOTSTRAP_SERVERS (or any other variable) as a normal
environment variable. The same pattern works for any TARGET_* variable — just change
the key name to match what your application expects.
Delete the ConfigMap in bin/stop.sh.template to keep the cluster clean:
kubectl delete configmap my-app-config --ignore-not-found
Wire protocol endpoint types
To expose a PostgreSQL or MySQL wire protocol port, use the corresponding endpoint type:
endpoints:
- name: "PostgreSQL wire"
node-type: db
port: 5432
type: postgresql
database: "mydb" # logical database name
- name: "MySQL wire"
node-type: db
port: 4000
type: mysql
database: "test" # logical database name
The database field is also available on jdbc endpoints to store the logical database name
separately from the JDBC URL path.
SQL capability
Database kits that expose a SQL interface should declare the sql capability:
capabilities:
- type: sql
user: default # default username
driver-class: com.clickhouse.jdbc.ClickHouseDriver # JDBC driver (optional)
The user and driver-class fields are used when constructing TARGET_JDBC_USER and
TARGET_JDBC_DRIVER for bench kits targeting this database.
Making an external kit targetable by bench kits
If you are writing an external kit that exposes a SQL interface and want bench kits like
sysbench to be able to target it, add three things to your kit.yaml:
1. A sql capability — declares the default username and (for JDBC) the driver class:
capabilities:
- type: sql
user: root
driver-class: com.mysql.cj.jdbc.Driver # omit if you don't expose JDBC
2. One or more wire protocol endpoints — the endpoint type determines which TARGET_*
variables the bench kit receives. Declare one per protocol your database supports:
endpoints:
- name: "MySQL wire"
node-type: app # or "db" — must match the node pool your kit runs on
port: 3306
type: mysql
database: "mydb" # the logical database name bench kits should connect to
- name: "PostgreSQL wire"
node-type: app
port: 5432
type: postgresql
database: "mydb"
- name: "JDBC"
node-type: app
port: 3306
type: jdbc
scheme: mysql
path: "/mydb?useSSL=false"
You only need to declare the protocols your database actually supports. A MySQL-compatible
database only needs the mysql endpoint; it does not need to also declare jdbc unless
you want JDBC bench tools to target it.
3. NodePort service on the declared port — the bench kit pod runs inside the same
Kubernetes cluster and connects via the app or db node's private IP. Make sure your kit's
start phase creates a NodePort service exposing the port you declared in the endpoint.
Once these three pieces are in place, a user can install sysbench (or any other bench kit
that declares capability: sql) against your kit:
easy-db-lab kit install sysbench --target <your-kit-name>
easy-db-lab sysbench-<your-kit-name> prepare
easy-db-lab sysbench-<your-kit-name> start
The capability check at install time will verify your kit exposes sql before writing
any files, so misconfigured targets fail immediately with a clear error.
Exposing Client Ports
A kit exposes its client ports through a NodePort Service, in the range 30000-32767, on a
fixed port that no other kit uses. Never use hostPort or hostNetwork for a client port:
hostPortonly works when the CNI chains theportmapplugin, which is a property of the datapath rather than of the kit.hostNetworkpins the pod to one host, and a port clash with another process shows up only as a CrashLoop.
A NodePort is reachable on every node's private IP, so declare the endpoint in kit.yaml with
the NodePort as its port and the node type that kit info should resolve. The existing kits
follow this: postgres serves on 30432 and clickhouse on 30123.
apiVersion: v1
kind: Service
metadata:
name: mykit-nodeport
labels:
easydblab/kit: mykit
spec:
type: NodePort
selector:
app: mykit
ports:
- name: client
port: 1234
targetPort: 1234
nodePort: 30999 # a port no other kit uses; Hubble UI owns 31234
Adding a New Kit
- Create
src/main/resources/com/rustyrazorblade/easydblab/kits/<name>/kit.yaml - Add lifecycle steps — start with
startandstopat minimum - Add a
metricsblock if the kit exposes Prometheus metrics - Add a
METRICS.mddocumenting the available metrics - Create
dashboards/<name>.jsonwith panels for the key metrics - Run
easy-db-lab kit install <name>to scaffold the working directory - Test
<name> startand<name> stopagainst a real cluster
No Kotlin code is required. The install and kit runner commands register dynamically from
the kit.yaml files at startup.