A Go HTTP service that exposes a REST API for viewing and managing Cluster API resources in a Kubernetes namespace.
The service can use either a local kubeconfig or a pod's ServiceAccount. It
also provides an OpenAPI specification and a Swagger UI or Stoplight Elements
interface.
Warning
This project is currently a prototype. The authentication middleware allows
every request. Cluster creation requires a configured cluster-defaults ConfigMap. Do not expose the service
publicly as-is.
- health checks through
GET /ping; - create, read, list, and delete CAPI
Clusterresources; - read a cluster kubeconfig from a Kubernetes
Secret; - cordon, uncordon and drain workload Nodes using dynamically created clients;
- expose optional cordon and draining states on nodes and clusters;
- list workload PersistentVolumes, linked to their XO VDI and attached nodes;
- interactive OpenAPI documentation with Swagger UI or Stoplight;
- Kubernetes access using a
kubeconfigor in-cluster configuration; - non-root Docker image;
- Kubernetes deployment through a Helm chart with namespace-scoped RBAC, probes, and optional Ingress and autoscaling.
Cluster list and detail responses include is_available, which is true only
when the CAPI Cluster Available condition is True. Missing, False, or
Unknown conditions yield false. This field can also be selected with
GET /kubernetes/clusters?fields=name,is_available.
Cluster responses also expose is_control_plane_initialized and
is_infrastructure_provisioned from CAPI status.initialization. Each field is
omitted when CAPI has not reported it; explicit false values are preserved.
These milestones remain true once completed and do not indicate current
availability. They can be selected with
GET /kubernetes/clusters?fields=name,is_control_plane_initialized,is_infrastructure_provisioned.
Cluster list and detail responses expose networking fields from CAPI
spec.clusterNetwork: api_server_port (defaults to 6443 when unspecified),
pod_cidr_blocks and service_cidr_blocks (arrays, including dual-stack ranges),
and service_domain. Unspecified CIDR blocks and service domains are omitted.
The API server bind port can differ from the public endpoint port. The existing
control_plane_endpoint contains the full HTTPS URL when available.
These fields support collection selection, for example
GET /kubernetes/clusters?fields=name,api_server_port,pod_cidr_blocks,service_cidr_blocks,service_domain,control_plane_endpoint.
Cluster and node list/detail responses expose is_deleting when Kubernetes
has set the resource's deletion timestamp; the field is omitted when false.
Cluster responses also expose is_scaling from current-generation CAPI
ScalingUp/ScalingDown conditions. Either true condition means scaling;
both known false conditions mean no scaling. Missing, unknown or stale
conditions leave the field omitted. Collection field selection supports
is_deleting and, for clusters, is_scaling.
Request validation now returns 422 Unprocessable Entity instead of 400. Clients that handle validation failures by status must accept 422.
| Status | Meaning |
|---|---|
| 400 | Request body cannot be decoded: malformed JSON, incompatible field type, or unknown field |
| 422 | Missing required field, invalid cluster name, invalid IP, replica count below 1, or invalid tag name/value |
| 404 | Cluster, Machine, Node or tag not found |
| 409 | Resource conflict, unregistered Node or drain blocked by pod configuration |
| 503 | Workload cluster or kubeconfig unavailable |
| 504 | Drain deadline exceeded; the Node remains cordoned |
| 500 | Internal failure or missing server configuration |
Errors use the existing Fuego response format (status, title, detail,
and optional errors for individual fields). For example, a missing cluster
name returns:
{
"status": 422,
"title": "Validation Error",
"detail": "Name is required",
"errors": [
{
"name": "ClusterCreateParams.Name",
"reason": "Key: 'ClusterCreateParams.Name' Error:Field validation for 'Name' failed on the 'required' tag",
"more": {
"nsField": "ClusterCreateParams.Name",
"field": "Name",
"tag": "required",
"param": "",
"value": ""
}
}
]
}An empty cluster creation body also returns 422 because its required fields are absent. Empty tag values remain allowed. Validation failures returned directly by the Kubernetes API are not covered by this mapping.
- Go
1.26.5, or a version compatible with thego.modfile; - access to a Kubernetes cluster;
- the Cluster API CRDs, including
clusters.cluster.x-k8s.io; - permissions on
clusters,machines, andsecretsin the target namespace; - optionally, Docker to build the image;
- optionally, Helm 3 to install the chart;
- optionally,
pre-commitandgolangci-lintfor local quality checks.
The Git hooks require pre-commit and golangci-lint to be available in your
PATH. On Debian or Ubuntu, install pre-commit in an isolated environment
with pipx:
sudo apt update
sudo apt install pipx
pipx ensurepath
pipx install pre-commitOpen a new terminal after pipx ensurepath, then check that the required
commands are available:
pre-commit --version
golangci-lint --versionSee the
golangci-lint installation guide
if the second command is missing.
After cloning the repository, install both the pre-commit and pre-push Git hooks:
make pre-commit-installThe pre-commit hook checks common file issues and runs golangci-lint on Go
changes. The pre-push hook runs the Go test suite. Run all pre-commit checks
manually with:
make pre-commitgo mod download
go run ./cmd/http_server \
--kube-client=kubeconfig \
--kubeconfig="$HOME/.kube/config" \
--namespace=default \
--address=127.0.0.1 \
--port=8080 \
--swagger-ui=swaggerCheck that the service is running:
curl http://127.0.0.1:8080/ping
# {"message":"pong"}The OpenAPI interface is available under /swagger/, and the JSON
specification under /swagger/openapi.json.
go run ./cmd/http_server \
--kube-client=in-cluster \
--namespace=capi-system \
--address=0.0.0.0 \
--port=8080 \
--swagger-ui=stoplightIn in-cluster mode, the client automatically uses the token and certificate
authority mounted in the pod.
All options are exposed as Cobra flags:
| Flag | Default | Description |
|---|---|---|
--kube-client |
kubeconfig |
Kubernetes configuration source: kubeconfig or in-cluster |
--kubeconfig |
$HOME/.kube/config |
Absolute kubeconfig path, used in kubeconfig mode |
--namespace, -n |
default |
Namespace containing the Cluster API resources |
--address |
0.0.0.0 |
HTTP server listen address |
--port, -p |
8080 |
HTTP server listen port |
--swagger-ui |
stoplight |
OpenAPI interface: swagger, stoplight, or disabled |
To display the help generated by the binary:
go run ./cmd/http_server --helpWith --swagger-ui=disabled, the user interface is disabled, but the
specification remains available under /swagger/openapi.json.
Pause controls CAPI reconciliation; it does not stop VMs or running workloads. The namespace is the one configured for the server.
# Replace these placeholders with the `id` values from the cluster and node responses.
CLUSTER_ID="<cluster-uuid>"
NODE_ID="<node-uuid>"
# Pause and resume a cluster.
curl --request POST "http://127.0.0.1:8080/kubernetes/clusters/${CLUSTER_ID}/reconciliation/pause"
curl --request POST "http://127.0.0.1:8080/kubernetes/clusters/${CLUSTER_ID}/reconciliation/resume"
# Pause and resume a Machine using its node UUID (`id`), not its name or VM UUID.
curl --request POST "http://127.0.0.1:8080/kubernetes/clusters/${CLUSTER_ID}/nodes/${NODE_ID}/reconciliation/pause"
curl --request POST "http://127.0.0.1:8080/kubernetes/clusters/${CLUSTER_ID}/nodes/${NODE_ID}/reconciliation/resume"These endpoints take no body and return 204 No Content after the requested
state is saved. Cluster pause sets spec.paused: true; resume removes that field.
Machine pause sets cluster.x-k8s.io/paused: "", overwriting any previous value;
resume removes only that annotation. Other fields and annotations are preserved.
Repeated requests for the same state succeed. A missing cluster or Machine, or a
Machine belonging to another cluster, returns 404; invalid parameters return
422. Concurrent modification returns 409; read the resource again and retry.
Cluster and node responses include is_reconciliation_paused, including when it is false, and
collection endpoints support ?fields=name,is_reconciliation_paused. For clusters this reflects
spec.paused; for nodes it reflects the Machine's own pause annotation, regardless
of its value. A Machine without its own annotation may still be affected by its
parent cluster's pause. Resuming a cluster preserves individual Machine pauses;
resuming a Machine preserves the parent cluster's pause. A Cluster pause annotation
set externally is also preserved: these endpoints control only spec.paused.
Deleting a paused cluster retains the existing behavior: deletion is requested
first, then its spec.paused field is cleared to allow controller cleanup.
These operations use the cluster UUID in {id} and the CAPI Machine UUID in
{nodeID}, matching the other node routes. The service resolves the cluster and
Machine UUIDs, checks membership against a fresh Machine read, then resolves
Machine.status.nodeRef.name to the workload Node. An unregistered Machine
returns 409; an absent Machine or one belonging to another cluster returns 404.
Set CLUSTER_ID and NODE_ID to the IDs returned by the cluster and node APIs.
curl --request POST "http://127.0.0.1:8080/kubernetes/clusters/$CLUSTER_ID/nodes/$NODE_ID/cordon"
curl --request POST "http://127.0.0.1:8080/kubernetes/clusters/$CLUSTER_ID/nodes/$NODE_ID/uncordon"
curl --request POST "http://127.0.0.1:8080/kubernetes/clusters/$CLUSTER_ID/nodes/$NODE_ID/drain" \
--header 'Content-Type: application/json' \
--data '{"timeout_seconds":300,"ignore_daemon_sets":true}'Cordon and uncordon are idempotent and only patch Node.spec.unschedulable.
Drain cordons automatically, then uses policy/v1 evictions with Pod UID
preconditions, respecting PodDisruptionBudgets and each pod's termination grace
period. It retries PDB throttling and waits for the affected pods to disappear.
There is no automatic uncordon after success, timeout, cancellation or partial
failure. An explicit uncordon returns the Node to service. An accepted eviction
cannot be undone by cancelling the HTTP request. Successful operations return
204 No Content; drain completion does not guarantee replacement pods are ready.
Send {} to drain with defaults. The optional body fields are:
| Field | Default | Meaning |
|---|---|---|
timeout_seconds |
300 |
Deadline for the operation, maximum 1800; 0 uses the default |
ignore_daemon_sets |
false |
Leave DaemonSet pods running instead of blocking the drain |
delete_empty_dir_data |
false |
Allow eviction of pods using emptyDir, accepting loss of that data |
force |
false |
Allow eviction of pods without a controller; PDBs still apply |
Mirror pods and completed pods are skipped. Blocking pod configurations return
409 before starting that round of evictions. A timeout returns 504, and the
request can be retried to continue a partial drain. The HTTP client and ingress
must permit requests lasting as long as the chosen timeout.
A workload cluster
is the cluster managed by Cluster API where user applications run. The management
cluster hosts the CAPI controllers, Machines and kubeconfig Secrets.
management_kubernetes_client accesses the management cluster;
workload_kubernetes_client accesses the workload cluster's Nodes and Pods.
For each operation the service reads <cluster>-kubeconfig, key value, from
the management namespace and creates a separate workload connection in memory.
Kubeconfig rotation takes effect on the next operation; no kubeconfig file is
written. The API endpoint must be reachable from the service. The kubeconfig's
identity needs get, list, patch on Nodes, list on Pods across namespaces,
and create on pods/eviction. These permissions belong to the workload cluster;
the management ServiceAccount retains its existing namespace-scoped permissions.
Node responses expose is_cordon from Node.spec.unschedulable. is_draining
is true when the Node is cordoned and at least one assigned pod has a
deletionTimestamp. This is an observed indicator: an unrelated pod deletion
can set it, and a drain blocked by a PDB can leave it false. It also observes
drains started outside this service without storing operation state.
Cluster list and detail responses aggregate these fields: true means at least
one Node is true; false means all known Nodes are false and none are unknown.
Unknown fields are omitted, while known false values remain present. Missing
Node references or an inaccessible workload API do not prevent returning the
available CAPI information. A pod-list failure can still return is_cordon;
is_draining remains known false on uncordoned Nodes. Reads list Nodes and Pods
once per workload cluster, with a five-second deadline per cluster. Reads for
different clusters run in parallel while preserving response order. Collection
field selection supports both fields, for example ?fields=name,is_cordon,is_draining.
GET /kubernetes/clusters/{id}/volumes lists the PersistentVolumes of the
workload cluster, whatever their provisioner. Cluster list and detail responses
embed the same list in volumes. The field is omitted when the workload cluster
cannot be read and is [] when it has no PersistentVolume, so field selection
such as GET /kubernetes/clusters?fields=name,volumes.name,volumes.source.csi.xo
distinguishes both cases. The dedicated route returns 503 instead.
{
"name": "pvc-3f2a",
"capacity_bytes": 10737418240,
"phase": "Bound",
"reclaim_policy": "Delete",
"storage_class": "xo",
"access_modes": ["ReadWriteOnce"],
"volume_mode": "Filesystem",
"created_at": "2026-11-04 10:00:00 +0000 UTC",
"claim": { "namespace": "app", "name": "data" },
"source": {
"type": "csi",
"csi": {
"driver": "csi.xenorchestra.vates.tech",
"volume_handle": "aaaaaaaa-0000-0000-0000-000000000010",
"xo": { "sr_id": "…", "pool_id": "…", "storage_type": "shared" }
}
},
"attached_nodes": [{ "id": "…", "node_name": "worker-1", "vm_id": "…" }]
}source.type is the Kubernetes PersistentVolume source field (csi, nfs,
local, hostPath, iscsi, rbd, …). Details are returned under csi, nfs
and local; other sources only report their type. For the Xen Orchestra CSI
driver, volume_handle is not the VDI UUID: the driver tags the VDI with
k8s:volumeId:<volume_handle> and k8s:pvName:<name>. source.csi.xo is
present for this driver only; sr_id, pool_id and storage_type come from
the driver's volume attributes. SR and pool names are not exposed because the
driver records them only at creation; resolve them from the IDs in XO.
A PersistentVolume belongs to the cluster, not to a Node. attached_nodes lists
the Nodes where CSI VolumeAttachments report the volume as attached; for an XO
volume this is the VM the VDI is plugged into. id and vm_id are resolved
from the cluster's Machines and are omitted for Nodes without a Machine.
node_affinity reports the required node affinity, which restricts where the
volume can be used, for example the Node of a local volume or the pool of an
XO volume.
Each read lists PersistentVolumes and VolumeAttachments once, with a
five-second deadline, through the same per-operation workload connection as
Node operations. The workload kubeconfig identity needs list on
persistentvolumes and storage.k8s.io volumeattachments. Cluster responses
read Node states and volumes concurrently for each cluster.
Base URL: http://localhost:8080.
| Method | Route | Description | Current status |
|---|---|---|---|
GET |
/ping |
Checks service health | Working |
GET |
/kubernetes/clusters |
Lists clusters, or selected cluster fields, in the namespace | Working |
GET |
/kubernetes/clusters/{id} |
Returns one cluster by UUID | Working |
GET |
/kubernetes/events |
Streams Kubernetes business transitions and a ping every five seconds | Working |
POST |
/kubernetes/clusters |
Creates a CAPI cluster and its associated resources | Requires an available version and configured defaults |
DELETE |
/kubernetes/clusters/{id} |
Deletes a cluster | Working |
PUT |
/kubernetes/clusters/{id}/description |
Replaces or clears the cluster description | Returns 204 |
POST |
/kubernetes/clusters/{id}/upgrades |
Starts a durable Kubernetes upgrade for one cluster | Working |
PUT |
/kubernetes/clusters/{id}/workers/replicas |
Sets the desired worker count | Working |
PUT |
/kubernetes/clusters/{id}/control-plane/replicas |
Sets the desired control-plane count | Working |
DELETE |
/kubernetes/clusters/{id}/nodes/{nodeID} |
Requests deletion of a node by UUID belonging to the cluster | Working |
POST |
/kubernetes/clusters/{id}/reconciliation/pause |
Pauses cluster reconciliation | Idempotent, returns 204 |
POST |
/kubernetes/clusters/{id}/reconciliation/resume |
Resumes cluster reconciliation | Idempotent, returns 204 |
POST |
/kubernetes/clusters/{id}/nodes/{nodeID}/reconciliation/pause |
Pauses a CAPI Machine by UUID | Idempotent, returns 204 |
POST |
/kubernetes/clusters/{id}/nodes/{nodeID}/reconciliation/resume |
Removes a CAPI Machine pause by UUID | Idempotent, returns 204 |
POST |
/kubernetes/clusters/{id}/nodes/{nodeID}/cordon |
Cordon the workload Node identified by a CAPI Machine UUID | Idempotent, returns 204 |
POST |
/kubernetes/clusters/{id}/nodes/{nodeID}/uncordon |
Uncordon the workload Node | Idempotent, returns 204 |
POST |
/kubernetes/clusters/{id}/nodes/{nodeID}/drain |
Cordon, evict pods and wait for drain completion | Returns 204, leaves Node cordoned |
GET |
/kubernetes/clusters/{id}/config |
Reads the associated kubeconfig | Convention-based |
GET |
/kubernetes/clusters/{id}/nodes |
Returns the cluster nodes, or selected node fields, from CAPI Machines | Working |
GET |
/kubernetes/clusters/{id}/volumes |
Lists the workload PersistentVolumes, or selected volume fields | Returns 503 if the workload cluster is unreachable |
GET |
/kubernetes/nodes |
Returns all CAPI Machines in the namespace | Working |
GET |
/kubernetes/versions-available |
Lists available Kubernetes releases grouped by minor version, newest first | Static catalog |
GET |
/kubernetes/tags |
Lists tags grouped by cluster name | Working |
GET |
/kubernetes/clusters/{id}/tags |
Lists cluster tags | Working |
GET |
/kubernetes/clusters/{id}/tags/{tag} |
Reads a tag | Working |
PUT |
/kubernetes/clusters/{id}/tags/{tag} |
Creates or updates a tag | Working |
DELETE |
/kubernetes/clusters/{id}/tags/{tag} |
Deletes a tag | Working |
GET |
/kubernetes/nodes/{id} |
Returns one node by UUID | Working |
Open the global stream for the service's configured Kubernetes namespace:
curl -N http://localhost:8080/kubernetes/eventsThe server flushes the response headers immediately, then sends a ping
every five seconds, starting five seconds after the stream opens:
event: ping
data: {"ping":1792058400000}
ping is a Unix timestamp in milliseconds, matching Date.now() in JavaScript.
EventManager produces heartbeats and business transitions through the
ForWatchingKubernetesEvents Strategy; the HTTP handler encodes and flushes
the SSE frames. EventClient observes Cluster, Machine and kubeconfig Secret
changes through the shared namespace-scoped informer cache. Cluster/node
creation, deletion, availability, reconciliation pause, tags, description,
kubeconfig availability, scaling and persisted upgrades are covered. The
catalogue and payload examples below describe all 26 business events and the ping.
No cluster name is required, and the stream also works when no clusters exist.
Initial informer lists do not generate creation notifications. Business payloads
identify resources by their API UUID (id) and Kubernetes UID (uid); there
are no SSE event IDs, replay or init events. The frontend must load REST state
on connection/reconnection and coordinate that read with incoming updates.
Each subscriber has a bounded queue of 128 business events; overflow closes
that subscriber's stream so it can resynchronize. Other subscribers continue
receiving events. Disconnecting releases the subscription and its ticker.
Scaling events use current CAPI ScalingUp/ScalingDown conditions and
aggregate both control plane and workers (context.group: "cluster"). Missing,
unknown or stale conditions do not imply completion. A completion notification
also requires convergence of ready/available replica counts. Upgrade events
follow the service's persisted upgrade operation; an external version change
without that operation is not classified as a tracked upgrade.
Apply the updated RBAC before deployment: kubeconfig observation needs
get/list/watch on Secrets in the configured namespace. Secret contents and
internal upgrade error messages are never sent to subscribers. is_deleting
is exposed on clusters/nodes when true; is_scaling on clusters is omitted
when the CAPI signal is unknown. SSE snapshots come from the management cluster
and do not enrich workload Node cordon/drain states.
Responses use text/event-stream, Cache-Control: no-cache, no-transform, and
X-Accel-Buffering: no. Proxies must preserve streaming and use compatible idle
timeouts. When routing through XO, ensure its CAPI path prefix matches this
service's /kubernetes prefix and its proxy forwards the streaming headers.
The HTTP server's absolute write timeout is disabled so streams can stay open
beyond Fuego's default 30 seconds. Each SSE header/frame write has its own
ten-second deadline, cleared between frames; clients that stop reading cannot
hold a blocked HTTP handler indefinitely. SSE uses the original response writer
without Fuego's access-log wrapper, which hides deadline support. Other routes
keep their existing logging. Each informer registration waits at most 30 seconds
for initial synchronization; failure aborts startup and removes already
registered event observers.
All events below are implemented and grouped by business function. Every
cluster and node event carries the full current snapshot, so a client can
replace its local copy on any of them. *.updated guarantees that a public
change is delivered immediately even when no business transition happens; when
a specific event is emitted for the same update, *.updated is not sent.
Cluster lifecycle and availability
| Event | Trigger |
|---|---|
cluster.created |
A Cluster appears after the initial informer list. |
cluster.available |
is_available changes from false to true. |
cluster.unavailable |
is_available changes from true to false. |
cluster.deleting |
The Cluster acquires a deletionTimestamp. |
cluster.deleted |
The Cluster disappears. |
cluster.updated |
The public cluster snapshot changes (phase, endpoint, replica counters...) and no more specific cluster event covers the update. |
Node lifecycle and readiness
| Event | Trigger |
|---|---|
node.created |
A Machine appears after the initial informer list. |
node.ready |
The public node status changes to Ready. |
node.not_ready |
The public node status leaves Ready, including for Unknown. |
node.deleting |
The Machine acquires a deletionTimestamp. |
node.deleted |
The Machine disappears. |
node.updated |
The public node snapshot changes (vm_id, ips, node_name, status, version...) and no more specific node event covers the update. |
Reconciliation pause and resume
| Event | Trigger |
|---|---|
cluster.reconciliation.paused |
Cluster spec.paused becomes true. |
cluster.reconciliation.resumed |
Cluster spec.paused becomes false or absent. |
node.reconciliation.paused |
The Machine pause annotation appears. |
node.reconciliation.resumed |
The Machine pause annotation is removed. |
Tags and description
| Event | Trigger |
|---|---|
cluster.tag.created |
A business tag is added. |
cluster.tag.updated |
An existing business tag changes value. |
cluster.tag.deleted |
A business tag is removed. |
cluster.description.changed |
The description changes or is cleared. |
Kubeconfig availability
| Event | Trigger |
|---|---|
cluster.kubeconfig.available |
The <cluster>-kubeconfig Secret has a nonempty value after being absent or empty. |
cluster.kubeconfig.unavailable |
A previously available kubeconfig Secret disappears or its value becomes absent/empty. |
Scaling
| Event | Trigger |
|---|---|
cluster.scaling.started |
A current CAPI condition confirms scaling has started. |
cluster.scaling.progress |
Replica counters or version distributions change between cluster.scaling.started and cluster.scaling.completed, including after CAPI stops reporting scaling while replicas are not yet ready. |
cluster.scaling.completed |
CAPI confirms scaling has stopped and replica counts have converged. |
Upgrades
| Event | Trigger |
|---|---|
cluster.upgrade.started |
A persisted upgrade operation becomes active with its lock held. |
cluster.upgrade.progress |
Replica counters or version distributions change during that operation. |
cluster.upgrade.completed |
The same previously active operation releases its lock and records completed_at. |
Only labels prefixed with tags.vmk.vates.tech/ produce tag events;
technical metadata changes do not. Tag contexts contain tag, previous_value
and value: null means absent, while "" is a present, empty value.
Reconciliation pause leaves VMs and workloads running. Each resource reports
its own pause; resuming a Machine does not resume its parent Cluster.
Nodes in this stream represent CAPI Machines. A Machine disappearing does not
prove that its XO VM has disappeared. A resource recreated under the same name
with a different Kubernetes UID produces deletion followed by creation.
deleting means deletion has started; deleted requires observed disappearance,
not merely an accepted HTTP DELETE.
Kubeconfig events report availability only. Rotating a nonempty kubeconfig does not produce an availability event. Availability observations are retained until the matching Cluster is observed, regardless of informer arrival order. Initial lists seed the availability baseline without notifications. A Secret owned by a different Cluster UID is not considered available for a recreated Cluster. The ping confirms data reception, not Kubernetes or cluster health.
Business events include the public resource snapshot, except cluster.deleted
and node.deleted, which carry identity only. Kubeconfig events carry only
identity and context.available, never a Cluster snapshot: apply them only to
the kubeconfig availability state, without replacing the Cluster record.
JSON id is the resource's API UUID, not an SSE replay ID; uid is the Kubernetes UID. Node events also
include cluster_id and cluster_name. observed_at is the server's observation
time. Removed optional fields, such as a cleared description or the last tag,
are omitted from the new snapshot. Read REST if workload cordon/drain enrichment
is needed. Immediate command errors remain in REST responses.
For example, this is an SSE frame with an abbreviated cluster snapshot:
event: cluster.tag.created
data: {"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","id":"0e04c4ca-a211-5ad7-942c-81be590bbf25","name":"demo","uid":"kubernetes-cluster-uid","context":{"tag":"team","previous_value":null,"value":"platform"},"cluster":{"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25","name":"demo","tags":{"team":"platform"}}}
The following examples preserve one fictitious payload per event type. They
represent independent scenarios, not a timeline. Each NDJSON line combines the
SSE event name with its decoded JSON data; this wrapper is not the raw SSE frame.
Payload examples for all 27 event types (NDJSON)
{"event":"ping","data":{"ping":1792663200000}}
{"event":"cluster.created","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","cluster":{"name":"demo","description":"Cluster de développement","phase":"Pending","created_at":"2026-10-14T08:00:00Z","is_available":false,"is_reconciliation_paused":false,"is_scaling":false,"is_upgrading":false,"tags":{"environment":"development"},"control_plane_status":{"desired_replicas":0,"replicas":0,"ready_replicas":0,"available_replicas":0,"up_to_date_replicas":0,"versions":[]},"worker_status":{"desired_replicas":0,"replicas":0,"ready_replicas":0,"available_replicas":0,"up_to_date_replicas":0,"versions":[]},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.available","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","cluster":{"name":"demo","description":"Cluster de développement","phase":"Provisioned","created_at":"2026-10-14T08:00:00Z","control_plane_endpoint":"https://10.30.138.14:6443","is_available":true,"is_reconciliation_paused":false,"is_scaling":false,"is_upgrading":false,"tags":{"environment":"development"},"control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]},"worker_status":{"desired_replicas":2,"replicas":2,"ready_replicas":2,"available_replicas":2,"up_to_date_replicas":2,"versions":[{"version":"v1.36.8","replicas":2}]},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.unavailable","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","cluster":{"name":"demo","description":"Cluster de développement","phase":"Provisioned","created_at":"2026-10-14T08:00:00Z","control_plane_endpoint":"https://10.30.138.14:6443","is_available":false,"is_reconciliation_paused":false,"is_scaling":false,"is_upgrading":false,"tags":{"environment":"development"},"control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]},"worker_status":{"desired_replicas":2,"replicas":2,"ready_replicas":1,"available_replicas":1,"up_to_date_replicas":2,"versions":[{"version":"v1.36.8","replicas":2}]},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.reconciliation.paused","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","cluster":{"name":"demo","description":"Cluster de développement","phase":"Provisioned","created_at":"2026-10-14T08:00:00Z","control_plane_endpoint":"https://10.30.138.14:6443","is_available":true,"is_reconciliation_paused":true,"is_scaling":false,"is_upgrading":false,"tags":{"environment":"development"},"control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]},"worker_status":{"desired_replicas":2,"replicas":2,"ready_replicas":2,"available_replicas":2,"up_to_date_replicas":2,"versions":[{"version":"v1.36.8","replicas":2}]},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.reconciliation.resumed","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","cluster":{"name":"demo","description":"Cluster de développement","phase":"Provisioned","created_at":"2026-10-14T08:00:00Z","control_plane_endpoint":"https://10.30.138.14:6443","is_available":true,"is_reconciliation_paused":false,"is_scaling":false,"is_upgrading":false,"tags":{"environment":"development"},"control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]},"worker_status":{"desired_replicas":2,"replicas":2,"ready_replicas":2,"available_replicas":2,"up_to_date_replicas":2,"versions":[{"version":"v1.36.8","replicas":2}]},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.deleting","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","cluster":{"name":"demo","description":"Cluster de développement","phase":"Provisioned","created_at":"2026-10-14T08:00:00Z","control_plane_endpoint":"https://10.30.138.14:6443","is_available":true,"is_reconciliation_paused":false,"is_deleting":true,"is_scaling":false,"is_upgrading":false,"tags":{"environment":"development"},"control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]},"worker_status":{"desired_replicas":2,"replicas":2,"ready_replicas":2,"available_replicas":2,"up_to_date_replicas":2,"versions":[{"version":"v1.36.8","replicas":2}]},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.deleted","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"node.created","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo-worker-abc","uid":"7ddf8a66-b40a-4dd0-9fd3-05c49dd62ab8","cluster_name":"demo","node":{"name":"demo-worker-abc","role":"worker","status":"Provisioning","is_reconciliation_paused":false,"$cluster":"0e04c4ca-a211-5ad7-942c-81be590bbf25","id":"8cb11b09-43c5-55eb-b108-33c662179d95"},"id":"8cb11b09-43c5-55eb-b108-33c662179d95","cluster_id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"node.updated","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo-worker-abc","uid":"7ddf8a66-b40a-4dd0-9fd3-05c49dd62ab8","cluster_name":"demo","node":{"name":"demo-worker-abc","role":"worker","status":"Provisioned","vm_id":"6b4967fc-cab4-4d90-a581-a09c8894c3da","is_reconciliation_paused":false,"$cluster":"0e04c4ca-a211-5ad7-942c-81be590bbf25","id":"8cb11b09-43c5-55eb-b108-33c662179d95"},"id":"8cb11b09-43c5-55eb-b108-33c662179d95","cluster_id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"node.ready","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo-worker-abc","uid":"7ddf8a66-b40a-4dd0-9fd3-05c49dd62ab8","cluster_name":"demo","node":{"name":"demo-worker-abc","role":"worker","status":"Ready","vm_id":"6b4967fc-cab4-4d90-a581-a09c8894c3da","is_reconciliation_paused":false,"$cluster":"0e04c4ca-a211-5ad7-942c-81be590bbf25","id":"8cb11b09-43c5-55eb-b108-33c662179d95","node_name":"worker-01","ips":["10.30.138.21"]},"id":"8cb11b09-43c5-55eb-b108-33c662179d95","cluster_id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"node.not_ready","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo-worker-abc","uid":"7ddf8a66-b40a-4dd0-9fd3-05c49dd62ab8","cluster_name":"demo","node":{"name":"demo-worker-abc","role":"worker","status":"NotReady","vm_id":"6b4967fc-cab4-4d90-a581-a09c8894c3da","is_reconciliation_paused":false,"$cluster":"0e04c4ca-a211-5ad7-942c-81be590bbf25","id":"8cb11b09-43c5-55eb-b108-33c662179d95","node_name":"worker-01","ips":["10.30.138.21"]},"id":"8cb11b09-43c5-55eb-b108-33c662179d95","cluster_id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"node.deleted","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo-worker-abc","uid":"7ddf8a66-b40a-4dd0-9fd3-05c49dd62ab8","cluster_name":"demo","id":"8cb11b09-43c5-55eb-b108-33c662179d95","cluster_id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.tag.created","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","cluster":{"name":"demo","description":"Cluster de développement","phase":"Provisioned","created_at":"2026-10-14T08:00:00Z","control_plane_endpoint":"https://10.30.138.14:6443","is_available":true,"is_reconciliation_paused":false,"is_scaling":false,"is_upgrading":false,"tags":{"environment":"development","team":"platform"},"control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]},"worker_status":{"desired_replicas":2,"replicas":2,"ready_replicas":2,"available_replicas":2,"up_to_date_replicas":2,"versions":[{"version":"v1.36.8","replicas":2}]},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"},"context":{"tag":"team","previous_value":null,"value":"platform"},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.tag.updated","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","cluster":{"name":"demo","description":"Cluster de développement","phase":"Provisioned","created_at":"2026-10-14T08:00:00Z","control_plane_endpoint":"https://10.30.138.14:6443","is_available":true,"is_reconciliation_paused":false,"is_scaling":false,"is_upgrading":false,"tags":{"environment":"production"},"control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]},"worker_status":{"desired_replicas":2,"replicas":2,"ready_replicas":2,"available_replicas":2,"up_to_date_replicas":2,"versions":[{"version":"v1.36.8","replicas":2}]},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"},"context":{"tag":"environment","previous_value":"development","value":"production"},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.tag.deleted","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","cluster":{"name":"demo","description":"Cluster de développement","phase":"Provisioned","created_at":"2026-10-14T08:00:00Z","control_plane_endpoint":"https://10.30.138.14:6443","is_available":true,"is_reconciliation_paused":false,"is_scaling":false,"is_upgrading":false,"control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]},"worker_status":{"desired_replicas":2,"replicas":2,"ready_replicas":2,"available_replicas":2,"up_to_date_replicas":2,"versions":[{"version":"v1.36.8","replicas":2}]},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"},"context":{"tag":"environment","previous_value":"development","value":null},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.description.changed","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","cluster":{"name":"demo","description":"Cluster de développement de l’équipe plateforme","phase":"Provisioned","created_at":"2026-10-14T08:00:00Z","control_plane_endpoint":"https://10.30.138.14:6443","is_available":true,"is_reconciliation_paused":false,"is_scaling":false,"is_upgrading":false,"tags":{"environment":"development"},"control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]},"worker_status":{"desired_replicas":2,"replicas":2,"ready_replicas":2,"available_replicas":2,"up_to_date_replicas":2,"versions":[{"version":"v1.36.8","replicas":2}]},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"},"context":{"previous_description":"Cluster de développement","description":"Cluster de développement de l’équipe plateforme"},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"node.reconciliation.paused","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo-worker-abc","uid":"7ddf8a66-b40a-4dd0-9fd3-05c49dd62ab8","cluster_name":"demo","node":{"name":"demo-worker-abc","role":"worker","status":"Ready","vm_id":"6b4967fc-cab4-4d90-a581-a09c8894c3da","is_reconciliation_paused":true,"$cluster":"0e04c4ca-a211-5ad7-942c-81be590bbf25","id":"8cb11b09-43c5-55eb-b108-33c662179d95","node_name":"worker-01","ips":["10.30.138.21"]},"id":"8cb11b09-43c5-55eb-b108-33c662179d95","cluster_id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"node.reconciliation.resumed","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo-worker-abc","uid":"7ddf8a66-b40a-4dd0-9fd3-05c49dd62ab8","cluster_name":"demo","node":{"name":"demo-worker-abc","role":"worker","status":"Ready","vm_id":"6b4967fc-cab4-4d90-a581-a09c8894c3da","is_reconciliation_paused":false,"$cluster":"0e04c4ca-a211-5ad7-942c-81be590bbf25","id":"8cb11b09-43c5-55eb-b108-33c662179d95","node_name":"worker-01","ips":["10.30.138.21"]},"id":"8cb11b09-43c5-55eb-b108-33c662179d95","cluster_id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"node.deleting","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo-worker-abc","uid":"7ddf8a66-b40a-4dd0-9fd3-05c49dd62ab8","cluster_name":"demo","node":{"name":"demo-worker-abc","role":"worker","status":"Ready","vm_id":"6b4967fc-cab4-4d90-a581-a09c8894c3da","is_reconciliation_paused":false,"is_deleting":true,"$cluster":"0e04c4ca-a211-5ad7-942c-81be590bbf25","id":"8cb11b09-43c5-55eb-b108-33c662179d95","node_name":"worker-01","ips":["10.30.138.21"]},"id":"8cb11b09-43c5-55eb-b108-33c662179d95","cluster_id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.kubeconfig.available","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","context":{"available":true},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.kubeconfig.unavailable","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","context":{"available":false},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.scaling.started","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","context":{"group":"cluster","control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]},"worker_status":{"desired_replicas":3,"replicas":2,"ready_replicas":1,"available_replicas":1,"up_to_date_replicas":2,"versions":[{"version":"v1.36.8","replicas":2}]}},"cluster":{"name":"demo","description":"Cluster de développement","phase":"Provisioned","created_at":"2026-10-14T08:00:00Z","control_plane_endpoint":"https://10.30.138.14:6443","is_available":true,"is_reconciliation_paused":false,"is_scaling":true,"is_upgrading":false,"tags":{"environment":"development"},"control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]},"worker_status":{"desired_replicas":3,"replicas":2,"ready_replicas":1,"available_replicas":1,"up_to_date_replicas":2,"versions":[{"version":"v1.36.8","replicas":2}]},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.scaling.completed","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","context":{"group":"cluster","control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]},"worker_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]}},"cluster":{"name":"demo","description":"Cluster de développement","phase":"Provisioned","created_at":"2026-10-14T08:00:00Z","control_plane_endpoint":"https://10.30.138.14:6443","is_available":true,"is_reconciliation_paused":false,"is_scaling":false,"is_upgrading":false,"tags":{"environment":"development"},"control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]},"worker_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.scaling.progress","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","context":{"group":"cluster","control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]},"worker_status":{"desired_replicas":3,"replicas":3,"ready_replicas":2,"available_replicas":2,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]}},"cluster":{"name":"demo","description":"Cluster de développement","phase":"Provisioned","created_at":"2026-10-14T08:00:00Z","control_plane_endpoint":"https://10.30.138.14:6443","is_available":true,"is_reconciliation_paused":false,"is_scaling":true,"is_upgrading":false,"tags":{"environment":"development"},"control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]},"worker_status":{"desired_replicas":3,"replicas":3,"ready_replicas":2,"available_replicas":2,"up_to_date_replicas":3,"versions":[{"version":"v1.36.8","replicas":3}]},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.upgrade.started","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","context":{"group":"cluster","control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":0,"versions":[{"version":"v1.36.8","replicas":3}]},"worker_status":{"desired_replicas":2,"replicas":2,"ready_replicas":2,"available_replicas":2,"up_to_date_replicas":2,"versions":[{"version":"v1.36.8","replicas":2}]},"operation_id":"upgrade-operation-example","target_version":"v1.36.9"},"cluster":{"name":"demo","description":"Cluster de développement","phase":"Provisioned","created_at":"2026-10-14T08:00:00Z","control_plane_endpoint":"https://10.30.138.14:6443","is_available":true,"is_reconciliation_paused":false,"is_scaling":false,"is_upgrading":true,"tags":{"environment":"development"},"control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":0,"versions":[{"version":"v1.36.8","replicas":3}]},"worker_status":{"desired_replicas":2,"replicas":2,"ready_replicas":2,"available_replicas":2,"up_to_date_replicas":2,"versions":[{"version":"v1.36.8","replicas":2}]},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.upgrade.progress","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","context":{"group":"cluster","control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":1,"versions":[{"version":"v1.36.8","replicas":2},{"version":"v1.36.9","replicas":1}]},"worker_status":{"desired_replicas":2,"replicas":2,"ready_replicas":2,"available_replicas":2,"up_to_date_replicas":2,"versions":[{"version":"v1.36.8","replicas":2}]},"operation_id":"upgrade-operation-example","target_version":"v1.36.9"},"cluster":{"name":"demo","description":"Cluster de développement","phase":"Provisioned","created_at":"2026-10-14T08:00:00Z","control_plane_endpoint":"https://10.30.138.14:6443","is_available":true,"is_reconciliation_paused":false,"is_scaling":false,"is_upgrading":true,"tags":{"environment":"development"},"control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":1,"versions":[{"version":"v1.36.8","replicas":2},{"version":"v1.36.9","replicas":1}]},"worker_status":{"desired_replicas":2,"replicas":2,"ready_replicas":2,"available_replicas":2,"up_to_date_replicas":2,"versions":[{"version":"v1.36.8","replicas":2}]},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
{"event":"cluster.upgrade.completed","data":{"observed_at":"2026-10-22T10:00:00Z","namespace":"capi-system","name":"demo","uid":"d16c56df-c370-4cb2-b70f-f04c243e9670","context":{"group":"cluster","control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.9","replicas":3}]},"worker_status":{"desired_replicas":2,"replicas":2,"ready_replicas":2,"available_replicas":2,"up_to_date_replicas":2,"versions":[{"version":"v1.36.8","replicas":2}]},"operation_id":"upgrade-operation-example","target_version":"v1.36.9"},"cluster":{"name":"demo","description":"Cluster de développement","phase":"Provisioned","created_at":"2026-10-14T08:00:00Z","control_plane_endpoint":"https://10.30.138.14:6443","is_available":true,"is_reconciliation_paused":false,"is_scaling":false,"is_upgrading":false,"tags":{"environment":"development"},"control_plane_status":{"desired_replicas":3,"replicas":3,"ready_replicas":3,"available_replicas":3,"up_to_date_replicas":3,"versions":[{"version":"v1.36.9","replicas":3}]},"worker_status":{"desired_replicas":2,"replicas":2,"ready_replicas":2,"available_replicas":2,"up_to_date_replicas":2,"versions":[{"version":"v1.36.8","replicas":2}]},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"},"id":"0e04c4ca-a211-5ad7-942c-81be590bbf25"}}
Keep curl -N http://localhost:8080/kubernetes/events running. In another
terminal, set a tag on a test cluster, replacing CLUSTER_UUID with its API UUID:
curl -X PUT http://localhost:8080/kubernetes/clusters/CLUSTER_UUID/tags/sse-test \
-H 'Content-Type: application/json' -d '{"value":"demo"}'An absent tag produces cluster.tag.created. Changing its value produces
cluster.tag.updated; repeating the same value produces no new notification.
Remove it to receive cluster.tag.deleted:
curl -X DELETE http://localhost:8080/kubernetes/clusters/CLUSTER_UUID/tags/sse-testPings continue every five seconds during these transitions.
List clusters:
curl http://127.0.0.1:8080/kubernetes/clustersExample response:
[
{
"name": "demo",
"phase": "Provisioned"
}
]Use the fields query parameter to choose the response representation. An
absent, empty value or * returns every field.
Otherwise, provide comma-separated JSON field names. Dot-separated paths select
nested fields. Unknown or malformed fields will be ignored.
Return full cluster objects:
curl 'http://127.0.0.1:8080/kubernetes/clusters?fields=*'Return only the cluster name and the ready replica count:
curl 'http://127.0.0.1:8080/kubernetes/clusters?fields=name,control_plane_status.ready_replicas'Both nodes collections (/kubernetes/nodes and
/kubernetes/clusters/{id}/nodes) use the same selection syntax:
curl 'http://127.0.0.1:8080/kubernetes/nodes?fields=name,version'List available Kubernetes versions before creating a cluster:
curl http://127.0.0.1:8080/kubernetes/versions-availableThe response is an ordered array of objects with version (the minor version)
and releases (the full versions). Both groups and releases are ordered newest
first. Choose a full version from releases, such as 1.36.9, for the creation
payload. The catalog is defined in pkg/components/version_manager; it is not
fetched from Kubernetes or an external release service.
Create a cluster:
curl --request POST http://127.0.0.1:8080/kubernetes/clusters \
--header 'Content-Type: application/json' \
--data '{
"name": "my-cluster",
"control_plane_replicas": 3,
"worker_replicas": 2,
"kubernetes_version": "1.36.9",
"control_plane_ip": "10.30.138.14",
"vm_name_prefix": "jls-capi-dev-",
"description": "Development cluster for the backend team",
"created_by_xo_user_id": "xo-user-123"
}'The HTTP API supplies the cluster name, replica counts, Kubernetes version,
control-plane IPv4 address, VM name prefix, description and tags.
The cluster name must be a lowercase RFC 1123 label: lowercase letters, digits
or hyphens, starting and ending with a letter or digit. Invalid names return
HTTP 422 with validation details. Generated resource names (including the
-cp, -worker and worker deployment suffixes) must fit within 63 characters.
The cluster-defaults ConfigMap in --namespace is read on each creation;
changes affect new clusters only.
To edit the description of an existing cluster:
curl --request PUT http://127.0.0.1:8080/kubernetes/clusters/demo/description \
--header 'Content-Type: application/json' \
--data '{"description":"Updated cluster description"}'Send {"description":""} to clear it. The description field is required and
must be a string (missing or null values return 422). Success returns 204
without a body; unknown clusters return 404, concurrent changes return 409,
and malformed bodies return 400.
The optional description accepts up to 4,000 Unicode code points, including
quotes, accents, emojis and line breaks (escaped as required by JSON). Longer
descriptions return HTTP 422. It is stored on the CAPI Cluster in the
vmk.vates.tech/description annotation and returned in creation, detail and list
responses. Empty descriptions and clusters without this annotation omit the
response field.
The optional string created_by_xo_user_id identifies the XO user who created
the cluster. It is stored in the CAPI Cluster annotation
vmk.vates.tech/created-by-xo-user-id and returned in creation, detail and list
responses. An absent or empty value is not stored and omits the response field.
This example reproduces the test cluster configuration. If my-cluster
already exists, creation returns 409. To create another cluster alongside it,
use a different name, an unused control-plane IP and a distinct VM name prefix.
Cluster creation creates a Cluster without spec.topology, an XOCluster, a
KubeadmControlPlane, one MachineDeployment, two per-cluster XOMachineTemplates,
and a per-cluster KubeadmConfigTemplate. The Cluster network comes from the
ConfigMap. The endpoint is configured on XOCluster and propagated by the provider.
The requested control-plane IP is also added to the API server certificate SANs.
Required ConfigMap keys:
| Key | Purpose |
|---|---|
workerName |
Worker deployment suffix, e.g. md-0 |
podCIDR, serviceCIDR, serviceDomain |
Cluster network |
controlPlaneEndpointPort, controlPlaneEndpointSubnet |
Endpoint port and IPv4 prefix length |
controlPlaneLoadBalancer |
kube-vip or an explicitly empty string |
cni |
Provider CNI setting: cilium or none |
controlPlaneMachineSpec, workerMachineSpec |
YAML objects containing the respective XOMachine spec: image, pool, networks, resources |
controlPlaneKubeadmConfig, workerKubeadmConfig |
YAML objects containing the respective kubeadm configuration and bootstrap commands |
Optional ConfigMap keys for Vates provider v1.1.0-rc6:
| Key | Purpose |
|---|---|
xoTags |
YAML list of additional Xen Orchestra tags applied to every cluster VM; defaults to no additional tags |
cilium |
YAML object with kubeProxyReplacement, and optionally k8sServiceHost and k8sServicePort; defaults to kube-proxy coexistence |
Both keys are strings containing YAML, like the machine configuration blocks. Older ConfigMaps without these optional keys remain supported. For example, in Helm values:
clusterDefaults:
xoTags: |
- production
- platform-team
cilium: |
kubeProxyReplacement: falsexoTags accepts up to 64 unique tags of 1–255 characters. These are independent
of the HTTP API's key/value tags, which remain Kubernetes labels. The provider
adds XO tags; removing a tag from its configuration does not remove it from
existing VMs. Machine-specific tags can also be set as a tags list in each
machine spec.
To enable Cilium's kube-proxy replacement for a new cluster, set
cni: cilium and set kubeProxyReplacement: true in the cilium YAML block.
Add skipPhases: [addon/kube-proxy] under
controlPlaneKubeadmConfig.initConfiguration so kubeadm does not install
kube-proxy. Preserve the other bootstrap settings. Cilium uses the control-plane
endpoint by default; a custom k8sServiceHost and k8sServicePort must be set
together and require replacement to be enabled. The endpoint must be reachable
from every node before Cilium starts. Cilium is installed using ApplyOnce;
upgrading the provider does not upgrade Cilium on existing clusters.
For IPAM, add the following to controlPlaneMachineSpec and/or
workerMachineSpec, preserving their other fields:
networkConfig:
networks:
- networkID: <your-xo-network-uuid>
addressesFromPools:
- apiGroup: ipam.cluster.x-k8s.io
kind: InClusterIPPool
name: my-cluster-poolInstall the IPAM provider and create its pools separately. Claimed addresses
override networkConfig.guestConfig; the generated network configuration uses
eth0, so put the address-bearing network first. Keep the control-plane VIP
outside the allocation pool.
These defaults apply to newly created clusters; changing the ConfigMap does not patch existing clusters or roll their nodes. Upgrade the Vates provider's image, CRDs and RBAC to RC6 before using the new fields. When using Helm to install the provider, explicitly update its CRDs as required by its installation method. The HTTP service's RBAC requires no additional permissions for these options. Creation, scaling and Kubernetes upgrades remain specific to kubeadm; Talos and Vates Kube OS are not supported by this service.
No Kubernetes templates need to be installed beforehand. The service builds
all resources from the request and ConfigMap, including the per-cluster machine
and worker bootstrap templates required by CAPI. It does not create or read a
KubeadmControlPlaneTemplate. Each YAML block contains configuration fields only,
without apiVersion, kind, metadata or a surrounding spec.
The chart defaults contain placeholders for XO VM image, pool and network
identifiers. Replace them before deployment. Both machine specs use kubeadm with
bootstrap.format: cloudinit; controlPlaneLoadBalancer: kube-vip enables
kube-vip injection on control-plane nodes. The deprecated bootstrap.cloudInit
and bootstrap.kubeVIP flags are no longer used. The image, pool and network
values are Xen Orchestra identifiers, not Kubernetes resource names. The provider
also accepts templateName and poolName; omitting the pool uses the template's pool.
The service decodes these configuration objects and leaves provider field
validation to Kubernetes and the Vates provider. The sample kubeadm
configuration follows the working AlmaLinux/RHEL cluster: it installs the exact
requested kubernetes_version, containerd and runc, disables swap, configures kernel modules
and sysctls, and enables the services. It requires a compatible dnf-based VM image
and access to the package repositories. Adapt these commands for other images.
Within preKubeadmCommands, the service replaces only {{KUBERNETES_VERSION}}
and {{CONTROL_PLANE_IP}} with the requested version and endpoint.
Shell variables such as $FULL, $IP and $(hostname) remain intact.
Credentials remain in provider-managed Secrets (the provider's global credentials
are used by this configuration).
The creation payload requires kubernetes_version, selected from GET /kubernetes/versions-available
(a full major.minor.patch release, for example 1.36.9, without the v prefix). Missing or unavailable
versions return HTTP 422 before reading defaults or creating resources. The
version is no longer read from the ConfigMap. An existing kubernetesVersion
key is ignored and can be removed. The service adds the v prefix internally
when building CAPI resources and rendering bootstrap commands.
The service sets VM name prefixes, replicas, Kubernetes version and resource
references from the request/defaults, and adds the endpoint IP to certificate
SANs. Other configured VM/bootstrap fields are preserved. MachineHealthChecks
are not created. The ConfigMap is decoded and all resources are built before
the first write. Generated resources belong to the Cluster. XOCluster additionally marks this
owner as its controller for compatibility with older Vates providers using
GetControllerOf; other resource ownership is unchanged. Cilium (or the configured
CNI), CCM and CSI are explicitly requested on XOCluster. The Cluster stays paused
until creation succeeds. On partial failure, delete the cluster through the HTTP
API and wait for cleanup before retrying. An activation timeout can leave the
cluster active: inspect its state before retrying.
When upgrading from the earlier configuration, replace the four template-name
keys with the four YAML blocks above and deploy the new service image. Updating
only the binary or only the ConfigMap is insufficient. The supplied Helm values
and k8s/deployment.yaml include the new structure.
Cluster tags are stored as labels on the CAPI Cluster resource under the
tags.vmk.vates.tech/ prefix. For example, the tag environment=production
becomes tags.vmk.vates.tech/environment=production. Tag names and values
must follow Kubernetes label syntax and are limited to 63 characters by the
Kubernetes label specification.
Tags may be supplied in the cluster creation request or managed later through
GET /clusters/{id}/tags, GET /clusters/{id}/tags/{tag},
PUT /clusters/{id}/tags/{tag} with {"value":"production"}, and
DELETE /clusters/{id}/tags/{tag} under /kubernetes. The cluster labels
are the persistent source of truth; tags previously kept only in process
memory do not migrate across a deployment.
The HTTP server delegates these operations to ClusterManager, which owns
the tag use cases and uses ClusterClient for Kubernetes label persistence.
The client enforces the constraints specific to Kubernetes labels. Tags are also
included in cluster creation, detail, and list responses.
GET /kubernetes/tags returns all tags grouped by cluster name in the
configured namespace, including clusters without tags. These response keys are
cluster names; item routes still use cluster UUIDs:
{"result":{"demo":{"environment":"production"},"no-tags":{}}}When there are no clusters, the response is {"result":{}}.
View a cluster and its nodes:
curl "http://127.0.0.1:8080/kubernetes/clusters/<cluster-uuid>"
curl "http://127.0.0.1:8080/kubernetes/clusters/<cluster-uuid>/nodes"The id fields contain the UUIDs used to address clusters and nodes in API
routes. A node id is derived from the cluster name and the CAPI Machine
resource name, so it is known as soon as the Machine exists and does not change
when the Kubernetes Node later registers. The name field contains the CAPI
resource name and is descriptive; it is not used to address deletion requests.
The optional node_name field contains status.nodeRef.name, the registered
Kubernetes Node name; it is omitted until the Node reference is available.
node_name is also descriptive: it is not used for deletion and does not
contribute to the node id.
Node responses expose ips instead of endpoint: an array of internal and external
IPv4/IPv6 addresses from the CAPI Machine's status.addresses, without a scheme
or port (for example, "ips": ["10.0.0.11", "2001:db8::1"]). Hostnames, empty or
invalid IPs, and duplicate addresses are excluded. The field is omitted when no
IP is available. Both node collections support ?fields=name,ips.
Node responses also include version from the CAPI Machine's spec.version,
preserving its format (for example, v1.36.0). The field is omitted when empty.
To select it for a cluster's nodes, use
GET /kubernetes/clusters/{id}/nodes?fields=name,version.
Delete a node using the cluster and node UUIDs from their id fields:
curl --request DELETE \
"http://127.0.0.1:8080/kubernetes/clusters/<cluster-uuid>/nodes/<node-uuid>"A successful request returns 204 No Content after Kubernetes accepts deletion.
CAPI controllers perform cleanup asynchronously, including their usual drain
and infrastructure cleanup; the endpoint does not bypass finalizers. The
configured namespace and the Machine's spec.clusterName restrict the target.
A missing cluster, missing node, or node belonging to another cluster
returns 404; an empty cluster or node UUID at the service boundary returns
422; a concurrent change detected by deletion preconditions returns 409
(the client can retry); unexpected Kubernetes errors return 500.
This operation deletes an individual node without changing the desired replica count. Its owning controller can create a replacement. To permanently reduce cluster capacity, use the replicas endpoints below.
curl --request POST http://127.0.0.1:8080/kubernetes/clusters/demo/upgrades \
--header 'Content-Type: application/json' \
--data '{"version":"1.36.5"}'
curl http://127.0.0.1:8080/kubernetes/clusters/demoThe request accepts an exact release from /kubernetes/versions-available, without
v, like the version supplied at cluster creation. It returns 202 with the accepted operation. The version in the response includes v to
match CAPI. Only this cluster's control plane and worker deployments are changed;
the ConfigMap and other clusters are untouched. A cluster must have a directly
referenced v1beta2 KubeadmControlPlane and at least one MachineDeployment.
Topology-managed clusters and OnDelete worker rollouts are unsupported.
Before starting, the service requires matching, fully converged control-plane and worker versions, available replicas, and Ready Machines with observed kubelet versions. It permits patch upgrades or the next minor version; downgrades, skipped minors, prereleases and unavailable releases return 422. An already converged cluster at the requested version returns a completed operation without a rollout.
The supported bootstrap is the one shipped with this service: each control-plane
and worker preKubeadmCommands must contain exactly one standalone
FULL="v<current-version>" assignment. The service updates this version anchor,
retaining the cluster's existing commands and settings. Unknown bootstrap layouts
are rejected with 409 before the cluster is locked. Upgrades never reload the
creation ConfigMap or replace arbitrary version strings in shell commands.
CAPI 1.13 observations without status.versions are supported: the service uses
control-plane status.version and worker generation/replica convergence. Observed
Machine and kubelet versions are still checked before starting and completing an upgrade.
A persistent operation in the Cluster annotation vmk.vates.tech/upgrade acts as
an exclusive upgrade lock. Lock acquisition uses a resourceVersion precondition,
so concurrent API replicas cannot start different upgrades. While active, the
same target returns the same operation and ensures its worker is running; a competing target returns 409. Scaling,
cluster/node deletion, and cluster/node pause/resume requests through this API
also return 409 while locked. Reads and tag changes remain available. This lock
does not pause CAPI or stop VMs; direct Kubernetes writes and external controllers
are outside the API lock and must be coordinated separately.
Scaling, pause/resume and deletion reserve the same Cluster through the
vmk.vates.tech/mutation-lock annotation before modifying any associated resource.
Upgrade acquisition and mutation reservations use optimistic patches on that
Cluster, so in-flight API mutations also exclude upgrades across service replicas.
Other clusters remain independent. Successful or definitively rejected mutations
release their reservation, including when request cancellation interrupts cleanup.
A crash, failed cleanup or write with an unknown outcome retains the reservation
without expiry. After verifying the owning request has ended and its resource
write has finished, an operator can remove that annotation to allow further work.
The HTTP handler calls ClusterUpgrader directly with the cluster UUID and requested
version. ClusterUpgrader validates the request and resource health, builds its
internal plan, persists the operation and lock, then starts a goroutine for that
upgrade only. Its lifetime is tied to the service, independently of the HTTP request.
The worker follows the rollout in order: control plane, workers, then final Machine
verification and unlock. Applying target changes and observing their convergence
are separate phases; waits only read resources and do not rewrite the operation.
Each phase checks only its cluster and operation every
10 seconds while waiting for CAPI or retrying an error; ready phases proceed
immediately, and there is no global polling loop when idle. Repeating the active target does
not start a duplicate worker in the same process. On startup, a single scan of
persisted active operations starts a worker for each interrupted upgrade. A corrupt
operation or individual read failure is logged and isolated to that cluster; it
does not prevent the API from starting. Failure to list active operations still
fails startup.
Progress is inferred from the current resources; no stage is persisted. The service
patches the control plane's version and bootstrap together, waits for its observed convergence, then
creates new per-operation worker bootstrap templates and patches each deployment's
version and bootstrap reference together. Source templates are retained unchanged.
Generation, replica counts, reported versions and observed Machine kubelet versions
must converge before the lock is released. GET /kubernetes/clusters/{id} exposes
is_upgrading, which remains true while the operation holds the lock, including
during retries. It becomes false after successful completion.
On an API error, unhealthy machine, stalled drain, or unexpected resource change, the operation keeps its lock and its worker retries. Inspect the service logs, the persisted operation annotation and CAPI conditions, fix the underlying problem, and let the worker continue. The worker exits when the operation completes or is replaced, the Cluster is deleted, or the service stops. Service shutdown retains the persisted lock for startup recovery. There is no automatic timeout unlock, downgrade, force-unlock endpoint, or automatic rollback. Only the latest operation is retained internally. Generated templates are owned by the Cluster and retained until cluster deletion.
Before production use, verify the installed CAPI/Vates and addon compatibility, VM image/bootstrap suitability, surge capacity, disruption budgets, removed APIs, and tested etcd/application backups. The service does not automatically validate these infrastructure and application prerequisites. Validate a complete rollout on a Vates test cluster first.
Set the number of workers:
curl --request PUT \
"http://127.0.0.1:8080/kubernetes/clusters/<cluster-uuid>/workers/replicas" \
--header 'Content-Type: application/json' \
--data '{"replicas":3}'Set the number of control-plane machines independently:
curl --request PUT \
"http://127.0.0.1:8080/kubernetes/clusters/<cluster-uuid>/control-plane/replicas" \
--header 'Content-Type: application/json' \
--data '{"replicas":3}'Both endpoints require an integer replicas between 1 and 2147483647. Zero
workers are not supported. With stacked etcd, the control-plane count must be
odd; external-etcd control planes may use an even count. The service reads
etcd configuration from the existing KubeadmControlPlane, not creation defaults.
A successful call returns 204 No Content once Kubernetes confirms the desired
count was written, including when that count was already set. CAPI reconciles
machines asynchronously and performs its normal drain and cleanup on scale-down.
The service changes only spec.replicas; it does not select individual machines
for deletion. Use GET /kubernetes/clusters/<cluster-uuid> and
GET /kubernetes/clusters/<cluster-uuid>/nodes to follow progress. Cluster
status, including desired_replicas, may temporarily lag behind the accepted
request.
This first version supports clusters without spec.topology, with exactly one
worker MachineDeployment and a KubeadmControlPlane. Workers are discovered in the
configured namespace using the cluster label and verified against spec.clusterName;
the control plane is resolved through spec.controlPlaneRef, without assuming
resource name suffixes. Cluster owner references, when present, must match the
current Cluster UID. Worker deployments with either CAPI autoscaler size
annotation cannot be scaled through these endpoints.
| Code | Meaning |
|---|---|
400 |
Malformed JSON, incompatible type, integer overflow, or unknown field |
404 |
Cluster not found |
409 |
Cluster/target paused or deleting, unsupported topology/provider, absent or ambiguous target, autoscaler management, ownership mismatch, or concurrent target change |
422 |
Missing/null replicas (including an empty body), count below 1, invalid stacked-etcd parity, or Kubernetes admission rejecting the count |
500 |
Unexpected Kubernetes or service configuration error |
Patches include the target's resourceVersion. A concurrent change returns
409 without automatic retries. Read the current state before submitting the
same desired count again. A timeout can leave the write outcome uncertain;
inspect the state before retrying. Each route performs at most one write, so
there is no cross-resource rollback. The Cluster and target state checks are
reads, not a transaction across resources: another actor can change the Cluster
or add a worker group between validation and the target patch.
The routes use the existing HTTP middleware; AuthRequired currently forwards
requests without enforcing authentication. RBAC permissions must be upgraded
along with the service (see Required RBAC permissions below).
Every cluster and node response includes a stable id field: a UUID v5
derived deterministically from the resource name. A cluster id is derived
from the Cluster name; a node id is derived from the cluster name and the
Machine name, never from the registered Node name. The same cluster or node
always produces the same UUID across process restarts, so clients can use id
as a stable key. Use it to fetch a resource directly:
curl http://127.0.0.1:8080/kubernetes/clusters/<uuid>
curl http://127.0.0.1:8080/kubernetes/nodes/<uuid>Each node entry also includes a vm_id field when the CAPI Machine
exposes a Xen Orchestra providerID (for example
xenorchestra://<pool>/<vm-uuid>). The server extracts the VM UUID and exposes
it as vm_id; if the provider ID is missing or not from Xen
Orchestra, the field is omitted.
This lets callers correlate a node with the corresponding Xen Orchestra VM without exposing provider strings in the API response.
Retrieve its kubeconfig:
curl "http://127.0.0.1:8080/kubernetes/clusters/<cluster-uuid>/config"The service resolves the cluster UUID to its CAPI resource name, then looks for
the corresponding <cluster-name>-kubeconfig Secret in the configured
namespace. It returns the value stored under the value key in a JSON object:
{"kubeconfig":"apiVersion: v1\nkind: Config"}Delete the cluster:
curl --request DELETE "http://127.0.0.1:8080/kubernetes/clusters/<cluster-uuid>"Successful cluster and tag deletions, as well as tag creation and updates, return
204 No Content with an empty body.
Build the image:
docker build --tag vmk-engine:local .Run the image with a read-only local kubeconfig mount:
docker run --rm --publish 8080:8080 \
--volume "$HOME/.kube/config:/config/kubeconfig:ro" \
vmk-engine:local \
--kube-client=kubeconfig \
--kubeconfig=/config/kubeconfig \
--namespace=defaultThe final image is based on Alpine, listens on port 8080, and runs as the
unprivileged user 10001.
Install the service in the namespace containing the Cluster API resources:
helm upgrade --install vmk-engine ./charts/vmk-engine \
--namespace capi-system \
--create-namespace \
--set image.repository=registry.example.com/vmk-engine \
--set image.tag=0.1.0If the Cluster API resources are in a namespace other than the Helm release
namespace, set config.namespace:
helm upgrade --install vmk-engine ./charts/vmk-engine \
--namespace services \
--create-namespace \
--set config.namespace=capi-system \
--set image.repository=registry.example.com/vmk-engine \
--set image.tag=0.1.0By default, the chart creates a Role in the target namespace and a
RoleBinding for the release ServiceAccount. Disable this behavior with
--set rbac.create=false when permissions are managed separately.
| Value | Default | Description |
|---|---|---|
replicaCount |
1 |
Replica count when autoscaling is disabled |
image.repository |
vmk-engine |
Image repository |
image.tag |
chart appVersion |
Image tag |
image.pullPolicy |
IfNotPresent |
Image pull policy |
config.namespace |
release namespace | Target CAPI namespace |
config.address |
0.0.0.0 |
Listen address |
config.port |
8080 |
Container port |
config.swaggerUI |
stoplight |
OpenAPI interface |
service.type |
ClusterIP |
Service type |
service.port |
80 |
Service port |
rbac.create |
true |
Create the Role and RoleBinding |
serviceAccount.create |
true |
Create the ServiceAccount |
ingress.enabled |
false |
Create an Ingress |
autoscaling.enabled |
false |
Create an HPA |
See charts/vmk-engine/values.yaml for
all available values.
kubectl --namespace capi-system port-forward service/vmk-engine 8080:80
curl http://127.0.0.1:8080/pingThe service uses the following minimum permissions in the target namespace:
| API group | Resources | Verbs |
|---|---|---|
cluster.x-k8s.io |
clusters |
get, list, watch, create, delete, patch |
cluster.x-k8s.io |
machines |
get, list, watch, delete, patch |
| core group | secrets, configmaps |
get |
cluster.x-k8s.io |
machinedeployments |
create, get, list, patch |
vates.infrastructure.cluster.x-k8s.io |
xoclusters, xomachinetemplates |
create |
controlplane.cluster.x-k8s.io |
kubeadmcontrolplanes |
create, get, patch |
bootstrap.cluster.x-k8s.io |
kubeadmconfigtemplates |
create, get |
watch is required on clusters and machines so the informer cache can
receive Kubernetes watch events and stay current without polling.
Secret access allows the service to read kubeconfigs. Treat the ServiceAccount and API access as sensitive.
Build the server:
go build ./cmd/http_serverRun the available tests:
make unitThe suite covers the cluster and version managers, Kubernetes clients using
fake clients, handlers, and HTTP routes using httptest. Tests also run with
go test ./...; the Make target additionally supplies the unit build tag.
Regenerate GoMock implementations after changing a Strategy interface:
make mocksRun static analysis and vulnerability scanning when the required tools are installed:
make lint # requires golangci-lint
make vuln # requires govulncheckHTTP client
|
v
HTTP component (Fuego routes and handlers)
+-- ForManagingVersions --------> VersionManager (static catalog)
+-- ForUpgradingClusters -------> ClusterUpgrader
| +-- ForManagingClusterResources --> ClusterClient
| `-- ForManagingVersions -------> VersionManager
|
v ForManagingClusters
ClusterManager
|
+-- ForManagingClusterResources --> ClusterClient -+-> informer cache (reads, UUID index)
+-- ForManagingClusterMachines --> MachineClient -+-> informer cache (reads, UUID index)
+-- ForGettingClusterConfig --> SecretClient --> Kubernetes API
+-- ForManagingWorkloadResources --> WorkloadClient --> Workload Kubernetes API
`-- ForGettingClusterCreationDefaults --> ConfigMapClient --> Kubernetes API
|
ClusterClient (writes only) --> Kubernetes API
cmd/http_server/main.go is the Configurator: it builds the Kubernetes client
and informer cache, registers UUID v5 field indexes, starts the cache goroutine,
waits for the initial sync, then creates the Strategies and the independent
ClusterManager and ClusterUpgrader components. Both components and the shared
VersionManager are injected into the HTTP component. This follows
Alistair Cockburn's Component + Strategy pattern.
ClusterClient holds both a direct client.Client (for writes and fresh reads
used by mutations and upgrades) and a cache.Cache (for list, get, UUID lookup
and tag reads). MachineClient also holds a direct client for node deletion and pause mutations. The UUID-based lookup routes
(GET /kubernetes/clusters/{uuid}, GET /kubernetes/nodes/{id}) resolve in O(1)
through UUID v5 field indexes registered on the informer cache at startup.
The shared VersionManager validates requested releases before creation defaults are read.
See architecture.md for the pattern's mapping to the code, runtime request flow, dependency rules, and current architectural gaps.
Main repository structure:
cmd/http_server/ entry point and CLI configuration
pkg/components/webserver/ HTTP routes, handlers, validation, and DTOs
pkg/components/cluster_manager/ cluster-management orchestration
pkg/components/cluster_upgrader/ upgrade validation, execution and recovery
pkg/components/version_manager/ available Kubernetes releases and validation
pkg/components/management_kubernetes_client/ Management-cluster Kubernetes Strategies
pkg/components/workload_kubernetes_client/ Workload Nodes, Pods, volumes and dynamic connections
pkg/strategies/ shared contracts and cross-contract models
charts/vmk-engine/ Helm chart
k8s/deployment.yaml basic Kubernetes manifest for local use
Dockerfile multi-stage image build
- implement proper HTTP authentication and authorization;
- enrich
/nodesMachine data with information from Kubernetes Node resources; - add server-side defaults and policy for cluster creation;
- extend the existing HTTP error mapping for additional Kubernetes failure cases;
- extend unit coverage and add integration and end-to-end tests;
- add observability, structured logging, and graceful shutdown;
- define an image versioning and publishing strategy.
Copyright (C) 2026 VATES SAS.
VMK Engine is licensed under the GNU Affero General Public License, version 3
or (at your option) any later version (AGPL-3.0-or-later).
See LICENSE for the full license text.