Repository navigation
v0.4.0
Fixed
- A non-JSON error body no longer discards the status code.
setApiErrorFromStatusJson
is best-effort, and the JSON request path had no fallback: when a load balancer or
ingress in front of the API server answered503with an HTML body, the caller got
error.K8sApiErrorwithlast_api_error == null— no reason, no message, not even
the status. The Protobuf path already handled this; both now do. last_api_errorno longer leaks across requests. It was cleared only when a
new KubernetesStatuserror replaced it, so a successful call — or any
transport-level failure, which never populates it — left the previous call's error
in place. A caller inspecting the field after catching an error (the documented
way to get error detail, and what the integration entrypoints do) reported a
stale, unrelated cause. It is now cleared at the start of every request attempt,
so it describes the current request or is null.- TLS options that cannot be honoured are now rejected instead of silently
dropped.K8sClient.initacceptedclient_cert_data,client_key_data,
client_cert_path,client_key_path,insecure_skip_verifyandserver_name
and then used none of them — only the CA was ever wired up. A caller configuring
client-certificate auth got an unauthenticated client and, much later, an opaque
error.TlsInitializationFailed. These now fail at construction with
error.ClientCertificatesUnsupported,error.InsecureSkipVerifyUnsupportedor
error.TlsServerNameUnsupported.- Zig 0.16's
std.crypto.tls.Clienthas no client-certificate support, and
std.http.Clientexposes no verification or SNI overrides, so none of these
can be implemented here today. - The README advertised mTLS as a supported feature with a worked example. That
claim is withdrawn.
- Zig 0.16's
error.TlsInitializationFailednow explains itself.std.http.Clientreturns
it for every handshake failure with the cause discarded. The real cause against a
Kubernetes cluster is thatstd.crypto.tls.Clienthas no handling for the
certificate_requesthandshake message (it appears nowhere in
std/crypto/tls/Client.zig), and an API server sends one whenever started with
--client-ca-file— the default everywhere. The handshake aborts with
TlsUnexpectedMessage. The client now logs the cause and thekubectl proxy
workaround. The README previously blamed self-signed certificates, which was
wrong: a publicly-trusted managed cluster fails identically.- A failed TLS handshake is no longer retried. It is deterministic, so the retry
loop only burned the backoff budget and repeated the diagnostic four times. K8sClient.initno longer leaks the system trust store on error paths. The CA
bundle rescan allocates before any later failure could return; added anerrdefer
and moved config validation ahead of all allocation.
Fixed (earlier in this release)
- Query values are now percent-encoded.
QueryWriter.addStringemitted values
raw, so any value containing a space or reserved character corrupted the HTTP
request target. This made the library's ownLabelSelector.addIn/addNotIn
unusable:app in (traefik,coredns)produced the request line
GET /...?labelSelector=app in (traefik,coredns) HTTP/1.1, which a spec-compliant
server rejects with 400 Bad Request — and the retry loop then repeated it
four times. Verified fixed against a live apiserver.- Continue tokens were never affected: the apiserver emits them with base64
RawURLEncoding, which is already URL-safe. - Query strings now read
fieldSelector=metadata.name%3Dmy-pod. The apiserver
decodes before parsing selectors, so behaviour is unchanged for values that
previously worked.
- Continue tokens were never affected: the apiserver emits them with base64
Performance
Pod.statusandNode.statusare typed rather thanstd.json.Value. An
untyped status is parsed into a DOM — one hash map per object, per item.
Measured on a 500-pod / 2.5 MB list: 5.33 ms -> 4.46 ms (-16%) and
10.5 MB -> 5.25 MB resident (-50%).PodStatus/ContainerStatusalready existed but nothing referenced them —
PodwasResource(PodSpec), whosestatusis dynamic. They are now used,
and extended to cover what real objects carry (conditions, podIPs/hostIPs,
qosClass, startTime, container state/lastState, image, containerID).ContainerStateis typed too, sostate.waiting.reason— the reason kubectl
prints in the STATUS column (CrashLoopBackOff,ImagePullBackOff) — no
longer needs a DOM lookup.NodeStatuscovers conditions, addresses, nodeInfo and images.capacity/
allocatablestay dynamic: their keys are open-ended (hugepages-*, vendor
devices).status.imagesis routinely the largest part of a Node object.- Reads of
pod.status.?.object.get("phase").?.stringbecomepod.status.?.phase.
getNodeCountno longer downloads the cluster. It listed every Node in full
and DOM-parsed the result to return one integer; it now requests?limit=1and
readsmetadata.remainingItemCount, so cost is independent of cluster size.ResourceClient.listPageswalks a collection page by page, following
continuetokens.list()buffers the entire collection, so memory scales with
the cluster and the request fails outright pastmax_response_size(16 MB by
default — roughly 3k pods).listPageskeeps both O(page). Covered by
tests/entrypoints/test_list_pages.zig, which needs no cluster.
Changed (breaking)
-
client.last_api_erroris removed; error detail is returned, not stored.
Capture it withrequestCapturing/requestCapturingWithContentType/
requestWithProtobufCapturing, or by settingResourceClient.error_sink. The
storage and the strings belong to the caller (ApiError.deinit(allocator)).The field was mutable per-request state on a shared object, with three consequences:
K8sClientcould not be shared across threads, even though thestd.http.Client
underneath is thread-safe ("Connections are opened in a thread-safe manner").
Every thread therefore needed its own client and its own connection pool. A
ResourceClientis a value, soerror_sinklives at the call site: one client now
serves many threads, each with its own sink. Exercised by
tests/entrypoints/test_error_capture.zig.- The strings were freed by the following request, so anything a caller kept became
a dangling reference. - It survived across calls (fixed earlier in this release, and now structurally
impossible).
A sink holds the detail of the most recent call made through it, or null. Each
call frees whatever the previous one left there, so a sink is safe to reuse and a
success clears it. (Assigning without freeing leaked the earlier status/message/
reason — reported by Cursor Bugbot on the PR — and leaving it in place brought
back the staleness this change set out to remove.)Migration:
// before _ = client.request(.GET, path, null) catch |err| { if (client.last_api_error) |e| { ... } }; // after var api_err: ?klient.K8sClient.ApiError = null; defer if (api_err) |*e| e.deinit(allocator); _ = client.requestCapturing(.GET, path, null, &api_err) catch |err| { if (api_err) |e| { ... } };
request,requestWithContentType,requestWithProtobufandrequestWithRetry
keep their signatures and simply report no detail.
Changed
- The JSON and Protobuf request paths now share one implementation.
requestWithProtobufcarried its own ~90-line copy of the send/receive logic —
URL building, auth, redirects, status handling, decompression, size limiting —
differing only in two headers. The copies had already drifted (only one had the
status-code fallback above, and a fix earlier in this release had to be applied
twice).K8sClient.WireFormatnow carries theContent-Type/Acceptpair and
sendOncetakes it.- Protobuf requests consequently follow the same retry policy as JSON ones
(idempotent methods retried, POST sent once). Previously they were never
retried, which was an accident of the duplication rather than a decision.
- Protobuf requests consequently follow the same retry policy as JSON ones
- The four list entry points share one fetch-and-parse body.
list,
listAll,listWithOptionsandlistAllWithOptionseach repeated the same
request/parse block; they are now thin wrappers that differ only in base path.
Verified against a logging server to produce byte-identical URLs.
Removed
PaginatedList— declared but never constructed by anything;listPages
supersedes it.src/k8s/kubeconfig_json.zig(212 LOC) — self-marked deprecated, shadowed by
kubeconfig_yaml.zig, and referenced by nothing: not bybuild.zig, not
re-exported fromklient.zig, not imported by any module or test.tls.TlsBundle,tls.createBundle,tls.CertInfo,tls.loadFromFilesand
tls.validateCertKeyPair— all existed to assemble or check a client
certificate/key pair, whichK8sClient.initnow rejects outright.createBundle,
CertInfoandloadFromFilesadditionally had no callers at all, and
loadFromFilesproduced aTlsConfigthatinitwould refuse. Supply a CA with
TlsConfig.ca_cert_path/ca_cert_datainstead.tls.decodeBase64Certstays —
it is still useful for CA data out of a kubeconfig.src/k8s/tls.zigshrank from 201 to 80 lines.
Added
EventSeries(count,lastObservedTime) andEvent.series. The modern
client-go/tools/eventsrecorder (kubeletBackOff,Unhealthy, …) collapses
repeated events intoseriesand leaves the deprecatedcount/lastTimestamp
unset. Consumers rendering COUNT / LAST-SEEN must preferserieswhen present,
as kubectl does — otherwise an aggregated series renders as count0with a
last-seen stuck at the first occurrence.PersistentVolumeSpec.claimRef(ObjectReference) for the PV CLAIM column.
Changed (breaking)
-
Eventis no longerResource(EventSpec). core/v1 Events carry
type/reason/message/involvedObject/count/timestamps as top-level
fields, which the generic spec/status wrapper silently dropped via
ignore_unknown_fields.Eventis now a flat struct with those fields.types.EventSpecis removed. Reads ofevent.spec.?.reasonbecome
event.reason.reportingController→reportingComponent(the former is the
events.k8s.io/v1spelling; core/v1 uses the latter).
-
New exports:
types.Event,types.EventInvolvedObject,types.EventSeries. -
Seven more kinds had the same defect and are now flat structs. Each has no
specin the Kubernetes API — their payload is top-level — so modelling them
asResource(T)parsed without error while dropping every field (specbound
tonull, the real keys eaten byignore_unknown_fields). Most visibly,
everyConfigMapread returned no data.Type Fields recovered ConfigMapdata,binaryData,immutable(new)EndpointssubsetsPodTemplatetemplateBindingtargetControllerRevisionrevision,dataEndpointSliceaddressType,endpoints,portsCSIStorageCapacitystorageClassName,capacity,maximumVolumeSize,nodeTopologyBindingwas also broken on the write path: it serialized as
{"spec":{"target":…}}, which the API server accepts and ignores, so binding a
pod to a node silently did nothing.Removed with them:
types.ConfigMapData,types.EndpointsSpec,
types.PodTemplateResourceSpec,types.BindingSpec,
types.ControllerRevisionSpec,types.EndpointSliceSpec,
types.CSIStorageCapacitySpec. Reads ofcm.spec.?.databecomecm.data.