Skip to content

The Kubernetes API client

Everything chant does against a live cluster — chant lifecycle diff --live, chant lifecycle plan, chant import --from, the kubectlApply and waitForReady Op activities, and behold’s overlay — goes through a typed API client rather than a kubectl subprocess.

Coverage. A declared entity type becomes an API address through a table generated by the same codegen pass that emits the declarable classes — 180-odd types, every bundled CRD included. The address is then confirmed against the cluster’s own API discovery, which is the only thing that knows the plural and the scope for the version that cluster serves. Before this, the lexicon carried a hand-written twenty-entry map, and every type outside it — every CRD, a HorizontalPodAutoscaler, a PodDisruptionBudget — was reported as unreadable.

Concurrency. Reads run through a bounded pool (eight in flight by default). A hundred declared entities are a hundred concurrent HTTP GETs sharing one connection and one cached credential, not a hundred serial process spawns.

Typed failures. The API server sends a Status object with a numeric code and a reason enum. Chant reads those fields instead of matching English on stderr, so 403 Forbidden becomes “not observed, no credentials” and 404 NotFound becomes a genuine absence, by construction rather than by regex.

No kubectl in the image. A Temporal worker running kubectlApply or waitForReady needs no kubectl binary. (The activity is still called kubectlApply: workers register activities by name, and renaming it would break every registered workflow.)

The client is @intentius/chant-k8s-client, an optional dependency of @intentius/chant-lexicon-k8s. A normal npm install brings it in; npm install --omit=optional does not.

Two things follow from it being separate:

  • chant build never resolves it. No module on the build path imports it, statically or otherwise — the lexicon reaches it through a dynamic import from modules only the observation and Op paths load. This is the first chant code that holds live cluster credentials, so that boundary is structural rather than a lint rule. examples/k8s-client-boundary.test.ts walks the static import graph and builds the whole k8s corpus to keep it that way.
  • If it is missing, chant says so. Observation reports every declared entity as not-observed with an install hint, rather than reporting an empty cluster — which would classify as a proposal to create everything.

The environment→cluster binding is unchanged from k8s.profiles.<env>.context:

chant.config.ts
import type { ChantConfig } from "@intentius/chant/config";
// Brings the `k8s` key into ChantConfig (chant #1344).
import "@intentius/chant-lexicon-k8s";
export default {
lexicons: ["k8s"],
k8s: {
profiles: {
prod: { context: "prod-eks" },
staging: { context: "staging-eks" },
},
},
} satisfies ChantConfig;

A declared binding is passed explicitly on every request. If the kubeconfig’s own current-context disagrees with it, chant refuses before touching a single resource and names both contexts — reading the wrong cluster would report every declared resource as missing, which is a confident and wrong list of creates. With no binding, the kubeconfig’s current-context is used and a warning says so.

On EKS, AKS and GKE, kubeconfig authentication is itself a subprocess: aws eks get-token, kubelogin, gke-gcloud-auth-plugin. So on the clusters most people actually run, the client still spawns a process — once for a token instead of once per resource read. That is the win, but it is worth stating rather than implying, because an exec plugin runs an arbitrary binary named in a file chant did not write.

Three things follow:

  • The plugin must be allowlisted. Those three plus kubectl are allowed by default. Anything else is refused by name, before it runs.
  • Tokens are cached for their stated lifetime. One KubeConfig is built per session and reused, so a 200-entity observation invokes the plugin once, not 200 times.
  • The credential path is recorded, so the provenance of an observation is legible: a read authorized by aws eks get-token and one authorized by a static service-account token are not equally trustworthy inputs to a drift report.

To allow another plugin, name it — this replaces the default list:

k8s: {
profiles: { prod: { context: "prod-eks" } },
execCredentialPlugins: ["aws", "my-org-oidc-helper"],
}

kubectlApply performs a server-side apply, rather than the client-side three-way merge kubectl apply does by default. Server-side apply is where Kubernetes itself has gone, it removes the last-applied-configuration annotation from the story, and it is what makes field ownership something the API server tracks rather than something chant has to infer.

ApplyOp({ target: "kubectl" }) goes through the same path. The kubectl branch of nativeApply used to shell kubectl apply -f; it now dispatches into this lexicon, where the apply is server-side and the prune runs against the typed client. The dispatcher itself stays in the Temporal lexicon, because “which mechanism applies this target” is not Kubernetes knowledge — but applying to Kubernetes is.

The manager chant applies as is derived from the project’s ownership stack:

ownership.stackfield manager
unset (or ownership.enabled: false)chant
webchant:web

This is the same identity the chant.intentius.io/stack label already carries. The label answers a binary, whole-object question — is this chant’s? — and server-side apply answers the sub-object version of it, per field, from the API server. One identity, two granularities.

ownership.env is deliberately not part of the manager. Two environments of one stack only ever touch the same object if they share a namespace and a name, and at that point they are fighting over it; an env-qualified manager would let each own half of it without either noticing.

To see what chant owns on a live object:

Terminal window
$ kubectl get deploy/web -n prod -o jsonpath='{.metadata.managedFields}' | jq

The entry whose manager is chant:<stack> and whose operation is Apply lists exactly the fields the manifest declared.

If another manager already owns a field the apply would set, the API server refuses with a 409 and says precisely which fields and who holds them. chant presents that rather than passing the raw status through:

k8s: server-side apply of apps/v1 Deployment prod/web was refused — 2 fields are
owned by another field manager.
"kubectl-client-side-apply" owns:
.spec.replicas
.spec.template.spec.containers[name="web"].image
chant applied as field manager "chant:web". Taking these fields means the managers above
stop owning them, and will contest them again on their next apply.
chant does not force this for you. Either:
- remove the contested fields from your chant source, leaving them to their current owner; or
- re-run this apply with force-conflicts on, deliberately (the `force: true` activity argument,
or `forceConflicts: true` on ApplyOp), which transfers ownership to chant.

The error is a FieldManagerConflictError — still a K8sApiError, so anything catching 409s keeps working — carrying conflicts, byManager, managers, fields and the fieldManager chant applied as.

ApplyOp’s delete: "owned-only" (and "gated") prunes chant-owned objects the manifest no longer declares, which is what kubectl apply --prune --selector app.kubernetes.io/managed-by=chant used to do. It keeps both of that command’s safety properties and narrows one:

  • Marker-scoped. The label selector goes to the server, and the marker is re-checked on every object before it is deleted. An object chant never stamped is never a candidate. With ownership.stack set, the selector also pins the stack, so one stack never prunes another’s.
  • Namespace-scoped to the apply. Namespaced kinds are swept only in the namespaces the apply set touched. A manifest that mentions prod never reaches into staging.
  • Kind-scoped to the union of the kinds in the apply set and chant’s default sweep set. The union is what lets a kind deleted from source entirely still be pruned — it appears nowhere in the apply set.

@intentius/chant-k8s-client exports the decoding primitives, since fieldsV1 is not a shape a caller should have to parse:

import { chantOwnedFields, fieldOwners, fieldSetsOf } from "@intentius/chant-k8s-client";
chantOwnedFields(live); // ['.spec', '.spec.replicas', …] — every chant manager
fieldOwners(live).get(".spec.replicas"); // ['chant:web', 'kubectl-client-side-apply']
fieldSetsOf(live); // one decoded entry per manager, subresources included

Paths render the way sigs.k8s.io/structured-merge-diff does — .spec.template.spec.containers[name="web"].image — which is the same syntax a conflict cause uses, so the two are comparable as strings. Subresource entries (a controller’s status writes) are excluded by default.

kubectl exec, attach, port-forward and cp need SPDY or the 1.30+ WebSocket subprotocol, are not on any critical path, and stay as they are. Chant is not reimplementing kubectl.

@intentius/chant-k8s-client/testing ships the fakes chant’s own tests use — a literal kubeconfig and a recording request layer that replaces the HTTP send and nothing above it, so kubeconfig parsing, context selection, credential policy, discovery and URL construction all still run for real:

import { fakeCluster, objectKey } from "@intentius/chant-lexicon-k8s/api/fake-cluster";
const cluster = fakeCluster({
objects: {
[objectKey("ray.io/v1", "RayCluster", "ml", "ray")]: {
apiVersion: "ray.io/v1",
kind: "RayCluster",
metadata: { name: "ml", namespace: "ray", uid: "uid-ray" },
status: { phase: "ready" },
},
},
});
await describeResources({ environment: "prod", /* … */ }, cluster.connector);
expect(cluster.layer.paths()).toContain("/apis/ray.io/v1/namespaces/ray/rayclusters/ml");

No test in this repository reads an ambient kubeconfig or opens a socket to a cluster.