A Kubernetes operator that provisions isolated development environments on demand. Declare what a team needs — a namespace, some storage, a database, a cache — and the operator builds it and tears it down again.
apiVersion: devenv.aviture.dev/v1alpha1
kind: DevEnvironment
metadata:
name: team-alpha
namespace: default
spec:
owner: team-alpha
storage:
size: 1Gi
services:
- name: postgres
image: postgres:16-alpine
port: 5432
mountPath: /var/lib/postgresql/data
- name: redis
image: redis:7-alpine
port: 6379$ kubectl get devenvironments
NAME PHASE NAMESPACE READY SERVICES AGE
team-alpha Ready team-alpha 2 2 30skubectl delete devenvironment team-alpha reclaims the whole namespace.
Each DevEnvironment owns one namespace and everything in it:
- a namespace, labelled for ownership and cost attribution
- a ConfigMap built from
spec.config, injected into every service - copies of any Secrets the services reference
- an optional shared PersistentVolumeClaim
- a Deployment and ClusterIP Service per requested service
- teardown of all of it, via a finalizer, when the resource is deleted
Services reach each other by name: postgres:5432, redis:6379.
Images are published to Docker Hub as
partofaplan/kado-operator,
built for linux/amd64 and linux/arm64.
Versions are RELEASE.MAJOR.MINOR, and each place moves on a different event:
| Event | Effect | Example |
|---|---|---|
merge into develop |
MINOR increments | 1.2.3 → 1.2.4 |
merge into main |
MAJOR increments, MINOR resets | 1.2.4 → 1.3.0 |
| a release package is cut | RELEASE increments, the rest reset | 1.3.0 → 2.0.0 |
CI assigns every version — never tag by hand. Cutting a release is the one step
a human starts, with gh workflow run release.yml; merging to main readies
work, cutting a release ships it.
Two tags are published, and only two:
| Tag | Points at | Moves |
|---|---|---|
RELEASE.MAJOR.MINOR (e.g. 1.2.3) |
one exact build, forever | never |
latest |
the most recently published version | every merge to develop or main, and every release |
Because versions only ever increase, latest is always the newest release on
either branch — including development builds. It is a convenience for "give me
the newest thing", not a stability channel: pin the version or a digest for
anything reproducible.
Older sha-<short> and develop tags exist on Docker Hub from before this
scheme and are frozen — nothing new is pushed to them. Every published commit
already has a version of its own, so a per-commit tag and a branch tag were a
second and third name for the same image.
This is not SemVer: the middle place marks a promotion of develop into
main, whatever the change contained. Breaking changes are called out in the
release notes, because no place in the version number signals them.
docker pull partofaplan/kado-operator:latest # newest published build
docker pull partofaplan/kado-operator:1.2.3 # one specific build, pinnedFor anything reproducible — a pinned deployment, a bug report, a rollback — use
the version tag or a digest rather than latest. Each CI run prints the
digest it published in its job summary.
Before the first release. The published chart,
install.yamland the GitHub release are produced by cutting a release, which is a deliberateworkflow_dispatchand has not happened yet. Until it does, install from a checkout — the command under from a checkout below works today and pulls thelatestimage, which moves on every publish.
helm install kado-operator \
https://github.com/partofaplan/kado-operator/releases/latest/download/kado-operator-chart.tgz \
--namespace kado-operator-system --create-namespaceOr from a checkout:
helm install kado-operator ./charts/kado-operator \
--namespace kado-operator-system --create-namespaceTo pin a specific published version — 1.2.3 below stands in for one from
the tag list:
helm upgrade --install kado-operator ./charts/kado-operator \
--namespace kado-operator-system --create-namespace \
--set image.tag=1.2.3Chart values are documented in
charts/kado-operator/values.yaml. The
image tag defaults to the chart's appVersion. The DevEnvironment CRD is
installed with the chart by default; set installCRDs=false where a platform
team owns CRDs separately.
Prefer plain manifests? Each release attaches a rendered install.yaml
(available once the first release is cut):
kubectl apply -f https://github.com/partofaplan/kado-operator/releases/latest/download/install.yamlThe operator runs on any conformant cluster. Point kubectl at one — K3D, Kind, minikube, Docker Desktop or a remote cluster — and everything below works the same:
kubectl config current-context # confirm the target
make install # install the CRD
make run # run the operator against it
# in another terminal
kubectl apply -f config/samples/devenv_v1alpha1_devenvironment.yaml
kubectl get devenvironments -wIf you need a throwaway local cluster, make cluster-up creates one
(LOCAL_PROVIDER=k3d|kind|minikube, default k3d).
examples/ has ready-to-apply environments for common setups —
Postgres or MySQL with a database UI, MongoDB, RabbitMQ, Prometheus and
Grafana, and a mail sandbox. Copy one and change the names and credentials.
make docker-build # build locally (tag with IMAGE_TAG=…)
make docker-push IMAGE_TAG=dev # build and push to Docker Hub
make docker-buildx # multi-arch build and push
make build-installer # render dist/install.yamlThe Dockerfile cross-compiles: its builder stage is pinned to
$BUILDPLATFORM, so a multi-arch build runs the Go toolchain natively once
per target rather than emulating it under QEMU.
Promoting develop into main readies a release; it does not cut one. On
merge, CI assigns the next MAJOR, tags the commit, and publishes the multi-arch
image and latest. No chart, no install.yaml, no GitHub release.
Cutting a release is a separate, deliberate step — the one a human starts:
gh workflow run release.yml -f dry_run=true # build it all, print the notes, change nothing
gh workflow run release.yml -f dry_run=false # publish for realThat moves the RELEASE place and creates a GitHub release with install.yaml,
the chart archive, the CRD and a sample environment attached. Versions are
never assigned by hand.
Every change reaches develop the same way — a feature branch, the
cluster-free test layers green, then a merge request reviewed by someone other
than its author. Nothing before the merge touches a cluster: a pull request
runs lint, codegen, unit tests, envtest and a Dockerfile build, and every stage
that needs a real cluster runs automatically after the merge — the
integration suite on an ephemeral cluster, then an upgrade of the published
image onto the live picard cluster to prove it provisions a real environment.
See the development workflow for the full set of gates.
CI needs one repository secret, DOCKERHUB_TOKEN: a Docker Hub access token
with Read/Write scope, never the account password. The username is not a
secret — it is the public partofaplan namespace already present in the image
name — so it lives in the workflow as a plain DOCKERHUB_USER env var. Pull
requests from forks cannot read secrets, so those runs build without pushing.
- DevEnvironment reference — every field, what gets created, status and conditions, troubleshooting
- Development workflow — branching, the local loop, testing layers, CI
make test # unit + envtest, no cluster required
make lintmake test and make lint are what gate a merge request. The integration
suite needs a cluster, so it is not a pre-merge gate — CI runs it
automatically after a merge into develop. Run it yourself whenever the
feedback would help, against a cluster of your own:
make test-integration # full lifecycle against the current kubectl context
make test-integration-local # ...or spin up a local cluster firstApache 2.0 — see LICENSE.