Skip to content

Trial: rebuild Kuber.jl on OpenAPI.jl 1.0 - #69

Draft
tanmaykm wants to merge 46 commits into
masterfrom
openapi-v1-trial
Draft

Trial: rebuild Kuber.jl on OpenAPI.jl 1.0#69
tanmaykm wants to merge 46 commits into
masterfrom
openapi-v1-trial

Conversation

@tanmaykm

Copy link
Copy Markdown
Member

What this is

A trial rebuild of Kuber.jl on OpenAPI.jl 1.0 (JuliaComputing/OpenAPI.jl#103), replacing the entire generated API layer and rewriting the verb layer against the new runtime. It follows OpenAPIv1TrialBranchPlan.md; OpenAPIv1RewriteNotes.md is the evaluation behind it.

Read OpenAPIv1TrialResults.md first. It is the implementation record: what was built, the five places implementation contradicted the plan and why, the measured numbers, and what is left. Where the plan and the results doc disagree, the results doc is current.

Draft, and it cannot merge as-is: Project.toml pins OpenAPI via [sources] to an unmerged commit (quinnj/OpenAPI.jl @ 1ff9ba8). Generated output is byte-stable only within a pinned commit, so when JuliaComputing/OpenAPI.jl#103 is merged and tagged the pin has to be dropped and everything regenerated.

Shape of the change

  • src/ApiImpl/generated/ — one generated module per Kubernetes group version (K8sV1, K8sAppsV1, …), 17 group versions, each carrying its own models, operations and embedded JSON Schemas. Replaces the old src/ApiImpl/api/ tree.
  • src/ApiImpl/generated/registry.jl (also generated) — the lookup tables the verb layer resolves through: GROUP_MODULES, MODULE_GVS, KIND_TYPES, OPS, OP_PARAMS, OP_BODIES. These replace api_typemap.jl/api_versions.jl and all the string-munging plus eval lookups.
  • src/helpers.jl, src/simpleapi.jl — rewritten. KuberContext now holds one Runtime.Client per group module (a client is bound to its module's compiled spec and cannot be shared), HTTP.jl 2.x request options, and a retry condition built from an actual characterization of the runtime's exception types rather than guesswork (test/characterize_retries.jl records the findings).
  • gen/openapi_v1/ — the reproducible generation pipeline: fetch pristine OpenAPI v3 documents from a kubernetes/kubernetes release tag, patch them with patch_k8s_spec.jq, generate in strict mode, emit the registry. See its README.
  • The verb API (get, list, put!, update!, delete!, watch, sel) is unchanged in shape.

Strict generation and strict response validation are on, throughout. A SchemaValidationError against a real cluster means the spec lies and the fix is a new patch rule — there is no validate_responses=false anywhere in src/. Two of the five patch rules were found exactly that way.

What changes for callers

  • Absent fields are ABSENT, not nothing — the one semantic change to watch for. nothing now means an explicit JSON null. Code testing "not set" with x.field === nothing must use Kuber._field(x.field) === nothing.
  • Model field names are lowercase, with a _ suffix on collisions: metadata.resourceversion, obj.apiversion, event.type. Type names are unchanged (IoK8sApiCoreV1Pod).
  • String maps are open structs, not Dicts. metadata.labels/annotations entries live in additional_properties; use kuber_props(pod.metadata.annotations)["key"].
  • Watch events are KuberEvent with an already-typed event.object; the kuber_obj round-trip is gone.
  • Every group module has its own copy of the shared meta types, so apps/v1's Status is not core's. Compare kuber_kind(res) == "Status", not the type — this matters for delete!.
  • Timeouts are HTTP.jl 2.x request options. set_timeout sets request_timeout; watches deliberately carry no overall deadline (bound them with timeout_seconds).
  • Errors are always KuberException — no (result, response) tuples.

Watches

The subtlest part, and worth reviewing closely. There are no dedicated watch operations — the deprecated /watch/ paths are deliberately not patched back in. Watching is watch=true on the list op with an accept-scoped codec (application/json;stream=watch), which fires only for calls that ask for it, because a real apiserver replies bare application/json. Since the generated call returns at the response head, list's watch branch pumps the stream inline and returns only when the watch is over — that is what keeps watch(processor, ctx, list, O) and its finally close(stream) blocks working.

Four watch-lifecycle bugs were found and fixed during the trial (consumer-close as the only stop signal, resume from the last resourceVersion, in-stream 410 restarting without one, and a re-watch spin on a 200-with-no-events). test/watch_recovery.jl covers all of it against a fake apiserver.

Testing

CI is green on Julia 1.11, 1 and nightly (run 31702176819) — 3824 offline assertions plus a live suite against a Kubernetes 1.35 kind cluster.

Suite Assertions Needs a cluster
test/registry.jl 3639 no
test/helpers.jl 94 no
test/simpleapi.jl 59 no
test/watch_recovery.jl 32 no (fake apiserver)
test/runtests.jl live suite ~303 yes

registry.jl is the generation gate: every table entry resolves and no deprecated watch* operation leaks in. The live suite is skipped with a warning when no server is reachable.

Note that CI's cluster version is now load-bearing, which it was not on the 0.2.x line: responses are validated against the v1.35.4 schemas the client was generated from, so the workflow pins kind v0.32.0 with the v1.35.5 node image. A spec bump has to move that pin with it.

Not in runtests.jl, kept as manual probes: test/characterize_retries.jl (pins the runtime's exception types) and test/watch_latency.jl (measured 5.6–11.2 ms reactions, 0 missed events).

Known limitations

Out of trial scope by decision:

  • Custom metrics throw a clear error; Metrics.md is annotated. custom.metrics.k8s.io needs a document captured from a cluster that serves it.
  • Aggregated APIs (metrics.k8s.io) and CRD groups likewise — neither appears in upstream release-tag specs.
  • One Kubernetes minor. The pipeline supports adding groups and minors; a multi-minor matrix is a post-trial concern.

Found during the trial:

  • TTFX for the first list is ~15 s. A PrecompileTools workload is the obvious next step.
  • Response validation is ~78 % of a steady-state list (11.5 ms). Correct and worth keeping for a trial, but a real cost for hot paths.
  • Shared meta types are per-module (see above). If this bites downstream the fix is upstream: hoisting shared schemas into one module.

Before this can come out of draft

  • Pure-julia OpenAPI internals rewrite OpenAPI.jl#103 merged and tagged; drop [sources], set compat to the tag, regenerate everything.
  • Verify on Julia 1.11 — done by CI, nightly green too.
  • Grep JuliaHub consumers for === nothing on model fields and for metadata.labels/annotations indexing.
  • Verify against a token-auth cluster — the trial only exercised kubectl proxy, so the credential path is untested against real TLS and bearer tokens.

Two things for reviewers to rule on: whether gen/openapi_v1_prototype/ (5 files of reference code from the evaluation) belongs in the repo or should be dropped before merge, and whether the three trial documents should be consolidated once this graduates.

tanmaykm and others added 30 commits August 13, 2026 16:08
Start of the openapi-v1-trial line (see OpenAPIv1TrialBranchPlan.md §1):

- deps reduced to Dates/HTTP/JSON/OpenAPI plus the Base64/UUIDs stdlibs the
  new generated modules import; Downloads, TimeZones and (unused) Random gone
- HTTP is now a direct dep: it activates OpenAPI's HTTP extension and the
  discovery probes use it directly
- [sources] pins quinnj/OpenAPI.jl @ 1ff9ba8 (package version 1.0.0); to be
  replaced by a tag + full regeneration when PR 103 merges
- floors: Julia 1.11, HTTP 2, JSON 1.7

Also checks in the evaluation notes, the trial plan, the runnable prototypes
every decision was verified against, and the watch-latency probe, so the
branch is self-describing.

src/ still references OpenAPI.Clients/Downloads, so `using Kuber` fails
cleanly here by design; Phase 1 replaces the generated layer.
Replaces src/ApiImpl/api (openapi-generator, Swagger 2.0, k8s 1.23-era) plus
api_typemap.jl/api_versions.jl with 17 OpenAPI.jl 1.0 client modules generated
from upstream kubernetes/kubernetes v1.35.4 OpenAPI v3 group documents, and a
registry of lookup tables over them.

Pipeline (gen/openapi_v1/, all inputs and outputs checked in so fetch -> patch
-> generate is reproducible and auditable):

- fetch_specs.sh pulls pristine group documents from a release tag and records
  provenance in SPECS_ORIGIN
- patch_k8s_spec.jq is the middle path: nullable meta.v1.Time, meta.v1.MicroTime
  and every array property (Go nil slices marshal as null), and NO /watch/ path
  rewriting — watching goes through the non-deprecated list ops
- generate.jl generates each patched document in strict mode (~30s)
- emit_registry.jl emits registry.jl

The registry replaces the string-munging + `eval` lookups it used to take to
find an operation:

  GROUP_MODULES  apiVersion -> module          (was APIVersionMap)
  MODULE_GVS     module -> apiVersion
  KIND_TYPES     (apiVersion, kind) -> type    (was Typedefs + kuber_type sniffing)
  OPS            (module, verb, kind, scope) -> operation function
  OP_PARAMS      same key -> positional argument names

Two departures from the plan sketch, both forced by the specs:

- OPS is keyed by module as well as (verb, kind, scope). A 3-tuple cannot
  express two shipped versions of one kind, and the trial ships two:
  autoscaling/v1 and autoscaling/v2 both define HorizontalPodAutoscaler.
- OP_PARAMS exists because generated positional order is path order — the
  namespace comes FIRST (`readcorev1namespacedpod(namespace, name)`) and a
  required body comes last, the reverse of the old client.

Two things the emitter learned from the specs rather than assuming:

- Subresource kinds come from the parent resource path, not the operation's own
  x-kubernetes-group-version-kind (which names the subresource type: pods/exec
  is PodExecOptions, pods/eviction is policy/v1 Eviction). Parent kind plus
  capitalized subresource reproduces the operationId tails exactly, so :PodLog
  reaches the table through the generic path instead of a special case.
- Generated model and function identifiers are read off the planner, not
  recomputed. The naming rules normalize non-identifier characters, dodge
  Base/Core and reserved names, and disambiguate collisions with a counter;
  a hand-rolled version got io.k8s.apiextensions-apiserver... wrong on the
  first try and would have drifted silently later.

Meta kinds (Status, DeleteOptions, WatchEvent, APIResourceList) are registered
under every group-version in every document and each module has its own copy,
so KIND_TYPES resolves them by policy: own group-version wins, then core, then
alphabetically first. Cross-module identity for those types is therefore not
guaranteed — `isa(res, kind_to_type(ctx, :Status))` on a non-core response is a
Phase 3/4 item.

src/Kuber.jl is reduced to the generated layer for now; helpers.jl and
simpleapi.jl still target the 0.2.x runtime and stay on disk as the porting
reference for Phases 2 and 3.

Gate (§6.1): pipeline clean, `using Kuber` precompiles in ~23s and loads in
~2.2s, test/registry.jl green (3639 assertions, no cluster needed).
Plan §4.3 asks for this before k8s_retry_cond is written, since the exception
types are runtime internals rather than a stable contract. A manual tool, not
part of runtests.jl; rerun it whenever the OpenAPI pin moves.

Six failure modes, probed against raw TCP servers (precise control over
truncated bodies and dropped connections) and the live k3s cluster. Three
results contradict what the plan assumed:

- the watch call never throws on consumer close. It returns at the response
  head, so there is no in-flight call to retry and the `isopen(stream)` guard
  has nothing to guard; retry-vs-stop has to live in the re-watch loop instead.
- a connection dropped on an item boundary closes the channel CLEANLY, exactly
  like a watch ending normally. A clean close therefore cannot be read as
  "stop" — only the consumer closing the channel can.
- 410 Gone is not an ApiError. k8s answers an expired resourceVersion with
  HTTP 200 and an in-stream ERROR event carrying a Status(reason=Expired).

The rest confirm the plan: retryable statuses arrive as ApiError with .status,
transport failures as HTTP.ConnectError (is_request_interrupted is gone), and a
truncated item closes the channel with DecodeError.
Context, clients, discovery, retries, exceptions and conversions, per plan §4.

KuberContext now holds a server URL and a lazily-built client per group module,
because a Runtime.Client is bound to its module compiled _SPEC and cannot be
shared across modules. Each client registers the watch codec at construction;
the codec is inert until a call passes accept=WATCH_MEDIA, so buffered calls on
the same client keep decoding typed models.

Discovery keeps its semantics (probe /api and /apis, tolerate groups we do not
ship, honour the `override` kwarg, preferred version first) but issues the two
probes with plain HTTP.jl: they were the only reason the old code needed the
generated ApisApi/CoreApi wrappers. ctx.modelapi is built from KIND_TYPES
rather than a names() scan, so it holds exactly the addressable kinds, and core
is registered first with earlier registrations winning — the meta kinds every
group redefines (Status, WatchEvent, DeleteOptions) resolve to core
deterministically instead of by dictionary order.

k8s_retry_cond encodes what test/characterize_retries.jl actually observed
rather than what the plan predicted. ApiError.status covers the retryable
statuses; transport failures are now stated as an exclusion over
HTTP.HTTPError, since HTTP 2.x has no RequestError and puts everything under
that supertype — retry the accidents, not the decisions (CanceledError,
StatusError, TooManyRedirects, AddressInUse, RetryDenied). The old
`isopen(stream)` guard is gone from the retry path entirely: a watch call
returns at the response head, so it can never be the in-flight call being
retried, and stop-vs-re-watch moves to the watch loop in Phase 3.

Timeouts move from the client mutable timeout[] to request_options on the
context, carrying HTTP 2.x names (request_timeout, connect_timeout,
read_idle_timeout, and TLS config). set_timeout sets request_timeout, and watch
calls drop it: a watch has no meaningful overall deadline, and k8s bounds one
with the timeoutseconds query parameter instead.

Gone with the 0.2.x runtime: check_api_response and the (result, response)
tuples (operations throw ApiError now), get_return_type sniffing, the
Downloads.Response header handling, the Connection header and httplib
selection, the int-or-string val_format extension (v3 declares IoIntOrString as
a real oneOf), and the convert() piracy on String/Dict. kuber_obj now decodes
through KIND_TYPES, and _field() is added for reading fields that may be ABSENT
— the one user-visible semantic change, since `nothing` now means an explicit
JSON null.

Gate (§6.2): test/helpers.jl green offline (82 assertions), and live discovery
against k3s v1.35.4 maps 16 groups and 101 kinds with the server preferred
versions ordered first.
The verbs keep their signatures and semantics; internals become registry
lookups (§5). Resolution is: module from the apiversion kwarg or ctx.modelapi,
then OPS[(module, verb, kind, scope)] with the old namespaced -> cluster ->
all-namespaces fallback chain as table probes instead of isdefined probes.
Positional arguments come from OP_PARAMS, so the namespace goes first and a
required body last. Snake_case kwargs are translated at the boundary
(label_selector -> labelselector) and nothing values are dropped rather than
forwarded, since generated optionals are Union{Absent,T} and an explicit
nothing fails request validation.

The watch rewrite is shaped by the characterization, not the plan sketch. The
generated call returns at the response head, so `list` cannot delegate and
return — it would close the stream immediately and yield zero events. Instead
its watch branch pumps the raw channel inline and returns only when the watch
is over, which keeps `watch(processor, ctx, list, O)` and both of its `finally
close(stream)` blocks working exactly as they did under the 0.2.x client. In the
loop: the consumer closing the public stream is the only stop signal (#67/#68),
a clean close of the raw channel means re-watch from the last resourceVersion
seen (a dropped connection on an item boundary is indistinguishable from a
normal end), a DecodeError means the same, and an in-stream ERROR event with
code 410 restarts without a resourceVersion — which is how k8s actually reports
an expired one.

Two further spec lies surfaced, both found by exercising verbs the evaluation
never had (it only ever listed and watched). Both are new patch rules, not
validation opt-outs:

- request bodies documented as `*/*`. Every create/replace body says `*/*`,
  which no client can encode to: there is no `*/*` encoder, and the runtime
  would send `Content-Type: */*` even if there were. Rewritten to
  application/json, which is what we send and what k8s accepts. Patch bodies
  keep their five explicit media types.
- DELETE 2xx responses documented as `Status`. A delete usually answers with
  the deleted object instead — verified live: deleting a Job returns the Job,
  deleting a Deployment returns a Status, so the ambiguity the old client hid
  behind get_return_type sniffing is real and both shapes occur. `oneOf:
  [Status, resource]` was tried and rejected (the generator emits one wrapper
  type per response code per media type — eight for a single delete). The
  responses now carry the empty schema, which states what is actually true, and
  delete! restores the type from the payload kind/apiVersion through
  KIND_TYPES — the same second-stage decode watch frames use.

update! wraps the patch in the generated Patch type (an open object, so a Dict
cannot be passed through) and validates patch_type against the documented media
list, since k8s offers no plain application/json for PATCH. Custom metrics throw
a clear out-of-trial error (§0). get_logs needs no special handling: the pod-log
response decodes to a String.

Registry gains OP_BODIES (body type + documented media types per operation) so
update! reads both from build-time tables rather than reflection.

Gate (§6.2): test/simpleapi.jl green offline (59 assertions, including the #67
watch-abort canary). Live against k3s v1.35.4: put!/update!/delete! round-trip a
Pod, a Deployment and a Job; watch delivers a typed initial list plus typed
ADDED/DELETED events; get_logs returns pod logs.
runtests.jl now runs the three offline suites first (registry, helpers,
simpleapi) and then the integration suite, which is skipped with a warning when
no API server is reachable and honours KUBER_TEST_SERVER. Expected diffs from
the 0.2.x suite, all of them semantic rather than cosmetic:

- the versioned-model test used batch/v1beta1 and batch/v2alpha1 CronJobs, and
  the override test used apps/v1beta2 and apiregistration.k8s.io/v1beta1. None
  of those exist on a 1.35 server; autoscaling is now the group that still
  serves one kind in two versions (v2 preferred, v1 available), so it is what
  exercises versioned typing and the `override` kwarg.
- Typedefs.CoreV1.WatchEvent -> KuberEvent, and event.object is already typed,
  so the kuber_obj round-trip in the watch assertions is gone.
- delete! assertions compare kuber_kind, not the type: each group module has its
  own Status type, so a batch/v1 delete can never be `isa` core Status.
- the timeout test is rewritten against request_options (there is no
  DEFAULT_TIMEOUT_SECS to compare with; unset means no deadline) and also
  asserts a watch drops request_timeout.
- new coverage: all-namespaces listing, a 404 surfacing as KuberException with a
  decoded Status, a rejected patch media type, and a Job + Deployment
  create/patch/delete round-trip.
- the `killall kubectl` teardown is dropped: it worked around a libcurl segfault
  on exit, and Downloads.jl is no longer a dependency.
- test_watch_processor_failure moved to test/simpleapi.jl, where it belongs with
  the other offline checks.

watch_latency.jl is adapted for the new API, and running it surfaced a real
usability trap: metadata.annotations decodes to a generated open struct, not a
Dict, so reading it by index silently failed and every MODIFIED event was
reported as MISSED. This is trap 6 of the plan (index open payloads via
additional_properties) meeting ordinary code. Added `kuber_props` to normalize
the shapes a k8s string map can take — ABSENT/null, a real Dict, or the struct —
since labels and annotations are far too common to make callers reach into
additional_properties themselves.

Docs: README gains a trial-branch section covering every user-visible change
(ABSENT vs nothing, lowercase field names, open string maps, KuberEvent,
per-module Status types, request options, what is out of scope);
SupportedAPIVersions.md is rewritten for the 17 generated group versions and
records what is deliberately absent; WalkThrough.md and Metrics.md get banners,
and WalkThrough loses a `!== nothing` check on a field that is now ABSENT.
CLAUDE.md is rewritten for the two-layer-plus-registry architecture, the watch
pump, and the five patch rules. CI drops Julia 1.6 for the 1.11 floor.

Gates (§6.3-§6.5): full suite green against k3s v1.35.4 — 3639 + 89 + 59 offline
and 303 live assertions. The prototype cross-check k8spristine_v3.jl passes
11/11 against the pinned OpenAPI, confirming upstream behaves as the evaluation
recorded. watch_latency reports 5-11 ms reaction times after warmup, so the
watch wrapper adds no buffering of its own.
…p spinning

test/watch_recovery.jl exercises the four watch criteria of §6 that need failure
injection rather than a cooperative cluster: a consumer stopping a watch, a
stream ending mid-watch (#68), a truncated item, and an expired resourceVersion.
It runs offline against a fake apiserver and is included from runtests.jl.

Writing it turned up three things.

The pump only noticed a stop when the next frame arrived. On a quiet resource
that can be minutes, which left `watch()` hanging and — under
`watch(streamprocessor, ...)` — kept the @sync alive after the processor died,
the exact deaf watch #67 fixed. A watcher task now closes the raw channel when
the consumer closes the public stream, so a stop takes effect in ~250ms
regardless of traffic.

The re-watch loop could spin. k8s_retry only wraps the *establish* call, so a
server that answers 200 and ends the stream without delivering anything is not a
failure and nothing throttled the next attempt — reachable with an unservable
resourceVersion or a proxy dropping long connections, and it would have hammered
the apiserver. Consecutive establishments that deliver no events now back off
0.25s to 8s, reset by any delivered event, and abandon the wait as soon as the
consumer stops. `wait(stopwatcher)` is guarded so it can never mask the error
that reached the finally block.

Also fixed: `get(ctx, O, name)` in a watch context clobbered a caller-supplied
field_selector with its own metadata.name filter; the two are now ANDed as k8s
expects.

The harness is built on HTTP.jl rather than raw TCP, after a hand-rolled chunked
server turned out to deliver nothing to the HTTP.jl client until the connection
closed — bytes curl streamed happily. Using one library on both ends keeps the
framing beyond question; the real apiserver framing stays covered live by
watch_latency.jl. Handlers hold responses open by polling a flag rather than
sleeping, because close(server) waits for in-flight handlers and a sleeping one
hangs teardown instead of failing the test.

Verified after these changes: full suite green against k3s v1.35.4 (3639 + 89 +
59 + 32 offline, 299 live) and watch_latency reports 0 missed events with
5.6-11.2ms medians, so the stop watcher and backoff cost nothing in the steady
state.
A connection aborted part-way through a chunk — an apiserver restart or a
network drop, the one #68 shape a clean stream end does not stand in for —
closes the watch channel with an HTTP.jl error (ParseError: unexpected EOF while
reading HTTP/1 data), not the DecodeError a truncated *item* gives. The pump
recovered from DecodeError only and rethrew everything else, so this killed the
watch outright.

The earlier characterization missed it because that probe used an unframed
response body, where an abort closes cleanly; real k8s is chunked. Verified
against an HTTP.jl server force-closed mid-chunk. The pump now re-establishes for
anything k8s_retry_cond accepts as well as DecodeError, which strictly widens
recovery and cannot regress the stop path — consumer close is checked first.
Asserted in test/helpers.jl at the decision point, since reproducing it
end-to-end needs the listener killed underneath the client, leaving nothing for
the retry to reach.

OpenAPIv1TrialResults.md records what the branch actually does: the five
deviations from the plan with their reasons, the §6 measurements (precompile
22s, load 0.36s, TTFX ~15s for the first list(ctx, :Pod), steady state 11.5ms of
which 78% is response validation, watch reactions 5.6-11.2ms), the test suite
inventory, the expected diffs in the adapted tests, and the follow-ups. The plan
gets a header pointing at it, since the plan is committed on this branch and is
the first thing a reviewer reads — five of its statements are now false, and
commit messages are not where design truth should live.

Full suite green after both changes: 3639 + 94 + 59 + 32 offline, 307 live.
…ated from

CI inherited master's kind pin (v0.11.1, node image kindest/node:v1.21.1). That
was harmless on the 0.2.x line but not here: the client is generated from the
v1.35.4 OpenAPI documents and strict response validation checks every reply
against those schemas, so the cluster version is part of the contract.

The first push failed exactly one assertion on all three Julia versions --
ctx.apis[:Autoscaling][1] is the v1 module because a 1.21 apiserver prefers
autoscaling/v1 (v2 went GA in 1.23). All 3824 offline assertions passed
unchanged on 1.11, 1 and nightly, so the branch was fine and the workflow was
not.

Pin kind v0.32.0 with the v1.35.5 node image (one patch release off the spec
tag; patch releases do not move the API surface) and record the CI result in the
results doc, including that the 1.11 compat floor is now exercised.
…ot provide

Adds OpenAPIv1ConsumerGaps.md, a working document to iterate on. Surveys
JuliaRun.jl (with JuliaHubK8sApi.jl), services/JobLoops, packages/K8sReflector,
JuliaRunPool, AccessControl and BillingService against what this branch provides
and what its tests cover.

The headline is that test coverage is the second-order problem. The consumers do
not use Kuber's generated layer at all: they use the verb layer over
JuliaHubK8sApi, which is a drop-in replacement for api/Kubernetes.jl +
api_typemap.jl + api_versions.jl, plugged in through KuberContext(apimodule) --
a parameter this branch removed. It also covers far more group versions than the
17 upstream ones shipped here, including CRD groups and custom.metrics.k8s.io
that gen/openapi_v1/ deliberately cannot produce from release-tag specs.

Split into seven hard incompatibilities (C1-C7) and sixteen test gaps (G1-G16),
each with a checkbox and a stable identifier to cite in review. The one silent
correctness item is G1: the pump swallows the in-stream 410 and re-watches with
no resourceVersion, so K8sReflector's cache-invalidation path is dead code and
its store keeps phantom entries for objects deleted while the watch was gone.
Mechanism verified against the code and against a live cluster.

Supersedes the narrower ABSENT/open-struct checklist line in the results doc.
… real usage

The first revision framed C1 as one big blocking item. It is two problems with
very different costs, and the mechanism half was overstated.

C1a, the mechanism, is cheap: _new_client is already duck-typed on the module
(mod.Client, no _SPEC reference anywhere in src/), the verb layer binds the
registry tables rather than their contents (const bindings to mutable Dicts, so
merge! is visible with no recompilation), and the keys make merging
conflict-free -- OPS is (module, verb, kind, scope), KIND_TYPES is (apiVersion,
kind). So registration is a merge! of six dicts, not a redesign. It must run in
the downstream package's __init__, since mutations to another module's state do
not survive precompilation. Restoring KuberContext(apimodule) is argued against:
it saves no regeneration and reinstates the per-context scoping that keying OPS
by module removed.

C1b, the content, is small once scoped. Adds an appendix auditing what
JuliaHubK8sApi's 43 group versions are actually used for: this branch's 17 are a
strict subset, and of the 26 extras only metrics.k8s.io/v1beta1 and
custom.metrics.k8s.io/v1beta1 are reachable through Kuber (v1beta2 needs a
runtime check). No CRD group is used at all -- JuliaRun's ServiceMonitor and
Prometheus manifests are kube-prometheus YAML applied by kubectl, and its one
karpenter reference is a node label string. The audit records how the dynamic
put!(cm, Symbol(job["kind"]), job) path was traced, since a grep for type names
would have been wrong three times over.

C1c prices the JuliaRun port: Typedefs is a generated tree of plain aliases, so
its ~28 references survive verbatim if the tree is re-emitted over the new type
names. Only the three WatchEvent/Status isa checks have to change.

C1d records the dynamic register_crd! option as deferred future-proofing rather
than a prerequisite, including that discovery already supplies plural, kind and
scope, and that it would bypass response validation.

C5 shrinks to the only remaining JuliaHub feature gap: two group versions plus
reimplementing the two custom-metrics helpers.
The 0.2.x plug point was `KuberContext(apimodule)`: a consumer handed Kuber a
drop-in replacement for its whole generated layer. This branch dropped it, which
is what blocks JuliaRun and JobLoops, since they reach k8s through
JuliaHubK8sApi rather than through the groups Kuber ships.

`Kuber.register!` replaces it by merging rather than substituting. The registry
tables are const bindings to mutable Dicts and every key carries either the
group version or the module, so an external layer's six tables merge in without
touching, or recompiling, what is already there. Registration is process-global;
resolution stays per-context, because ctx.apis and ctx.modelapi are still built
at discovery from whatever the server serves.

Validation runs over the whole registration before any of it is merged, against
the invariants test/registry.jl asserts over the merged result, so a malformed
layer cannot half-load. A group version already served by a different module is
an error rather than a silent override, and re-registering identical content is
a no-op. `unregister!` undoes a registration and refuses to touch the group
modules Kuber ships.

No registry-generation counter: both consumers load their layer with a top-level
`using`, so __init__ runs before any context exists, and invalidating contexts
automatically would mean changing the ctx.initialized guard two test suites set
by hand to fake discovery.

Tested offline against a hand-written fake group module — no spec, no server.
Every registration is undone in a finally, since the tables are global and
test/registry.jl asserts invariants across all of them.
The docstring claimed register! checks the invariants test/registry.jl asserts
over the merged result, and two families were missing: the OP_PARAMS shape
(namespace first when namespaced, absent otherwise, body last and only for verbs
that send one, no repeats) and the trap that no deprecated /watch/ operation
leaks in.

The params ones have teeth beyond doc accuracy, because _positional consumes
OP_PARAMS positionally: a mis-emitted external table would be accepted at load
and fail confusingly at call time.

Also fix the README snippet, which was not runnable as printed — the emitted
registry names its group modules bare, so they have to be included first, and
the module needs Kuber in scope. That snippet is the copy-paste target for
whoever regenerates JuliaHubK8sApi.
k8s answers a watch resumed from too old a resourceVersion with an in-stream
ERROR event carrying Status(code=410). The pump consumed it and re-watched with
no resourceVersion at all, which k8s answers by replaying current state as
synthetic ADDED events. That recovers the connection but not the truth: nothing
ever mentions objects deleted while the watch was gone, so a consumer keeping a
cache holds phantom entries for the life of the process. K8sReflector's 410
handler — which invalidates its store — was dead code against this branch.

The pump now lists again on expiry, pushes that list onto the stream, and
watches from its resourceVersion, which is what client-go's reflector does. It
removes the phantom-entry class rather than delegating it: a consumer that
replaces its store on a list frame is correct without knowing expiry exists.

The contract that makes it one rule rather than two: a list object on the stream
means complete current state. It was already the first frame; now it is also the
resync frame, so a consumer written against the initial list already handles it.
A KuberEvent("RESYNC", …) marker was considered and rejected — two shapes for
"here is full state" is worse than one, and on the get path (whose initial frame
is a single object) a resync-typed event would be stranger than a plain list.

The 410 ERROR frame is still not delivered: the list carries strictly more than
the Status did, and surfacing an error for a condition Kuber recovers from would
break consumers that treat ERROR as fatal.

push_initial=false suppresses the resync frame too — it is the caller asking for
events only, and watch(ctx, O, stream) sets it. Such a consumer still gets the
recovery, since the re-list is where the new resourceVersion comes from, but not
the state, and has to track expiry itself. Documented rather than papered over.

The fake apiserver's list body is now a function of the request number, so the
test can tell re-listing apart from starting over: p1 exists at the first list
and is gone by the second — exactly the object a replay would never mention.
…enAPI.Clients

K8sReflector imports `OpenAPI.Clients: is_longpoll_timeout, is_request_interrupted`
to decide whether a dead watch is worth reconnecting. Neither name exists in
OpenAPI.jl 1.0, and Kuber had the equivalent judgement internally
(k8s_retry_cond, _DECISIVE_HTTP_ERRORS) without exporting it, so a consumer had
no supported way to ask.

is_retryable(e) is that judgement, named for what it means rather than for what
it replaces: "would Kuber retry this", which is the question a consumer driving
its own calls has to answer, and it matches the internal vocabulary.

It unwraps TaskFailedException and single-exception CompositeException first.
That is not incidental — watch(processor, ctx, …) runs the watched call and the
processor under @sync, so a failure reaches the caller wrapped, and a predicate
that did not unwrap would answer false for every watch failure, the exact case
it exists for. A composite carrying more than one exception is left alone: there
is no single cause to classify.

is_longpoll_timeout has no successor and needs none — watches carry no overall
deadline here, so one never ends on a timeout; it ends when the consumer closes
the stream, which is not an exception. The C2 entry now carries a migration
table for all four lost names, including five `OpenAPI.Clients.ApiException`
sites in JuliaRun that the original survey missed.
…o again (C1b)

Aggregated APIs and CRD groups are absent from kubernetes/kubernetes release
tags because they are not part of Kubernetes: metrics.k8s.io is served by
metrics-server, custom.metrics.k8s.io by an adapter, CRD groups by whatever
installed them. A live apiserver serves a real OpenAPI 3.0.0 document for each
at /openapi/v3/apis/<group>/<version>, so fetch_specs.sh grows a second source
mode for exactly that.

Provenance goes to SPECS_CAPTURED rather than SPECS_ORIGIN: separate files so
neither mode clobbers the other's record, and because a captured document is
only as reproducible as the cluster it came from — recorded as server version,
context and date. The tag mode also stopped listing files it did not fetch.

metrics.k8s.io/v1beta1 is captured (k3s v1.35.4) and shipped. The existing patch
rules covered it unchanged, strict generation passed first time, and the other
17 modules regenerated byte-identically. test_metrics covers it live under
strict response validation: node metrics, pod metrics in one namespace and
across all of them, and single objects of each. It skips with a warning when the
cluster has no metrics-server, which includes CI's kind cluster — the group's
absence says nothing about whether the client is right.

Shipping it *in Kuber* corrects a call OpenAPIv1ConsumerGaps.md made earlier.
The 0.2.x line shipped metrics.k8s.io in Kuber — master's SupportedAPIVersions.md
lists metrics_v1beta1 and api_typemap.jl has the MetricsV1beta1 aliases — and
Metrics.md is a Kuber document, so dropping it was a regression for every Kuber
user rather than a JuliaHub-specific gap. The line to draw is whether any user of
the API could plausibly have the group: metrics-server yes, an operator's CRDs
no. Deployment-specific groups still belong in their own package, plugged in
with Kuber.register!.
…need (C5)

On master, list_custom_metrics and list_namespaced_custom_metrics are one-liners
over list(ctx, :MetricValue, "<objecttype>/<name>/<metric>"). The trial replaced
them with an error, because custom.metrics.k8s.io was not generated. They are
those one-liners again, so the group only has to be captured and registered for
JuliaRun's metrics path to work.

Two things had to come back or give way first:

list(ctx, O, name) — the trial's list took no name, so there was nowhere to put
a composite metric name. It is an error to pass one to a list operation with no
path parameter for it, rather than being silently ignored.

_positional no longer insists the path parameter be called `name`. Every group
the apiserver serves calls it that, but emit_registry.jl takes path parameter
names verbatim from the document, and custom.metrics.k8s.io's path is
/namespaces/{namespace}/{compositemetricname} — so the verb layer would have
rejected the very operation the pipeline generated for it. There is only ever
one such parameter, so the name argument fills whichever it is; more than one is
now a clear error instead of a confusing one. This would have bitten any
captured group with a non-standard path, not only this one.

What is not here is the group document. custom.metrics.k8s.io exists only where
an adapter is installed; no public document defines it (master's came from
definitions hand-spliced into the legacy Swagger file) and no cluster reachable
from here serves it. Hand-authoring an OpenAPI 3 document from master's fragment
was considered and rejected — strict response validation would then enforce a
spec nobody could check. The honest version is one --from-cluster capture on a
cluster that has the adapter.

Tests pin what is verifiable without one: the three composite-name shapes, the
helpers reporting an unregistered group rather than erroring outright, the
non-`name` path parameter, and a name refused where the operation takes none.
…h (G13)

Every write in the suite went through the typed 2-arg form, while
services/JobLoops writes through put!(ctx, :Deployment, spec) with a dictionary
rendered from a template. The testset is modelled on hot_standby.jl rather than
invented: the namespace dict is that file's literal shape, and the deployment
comes from JSON.parse of a template.

That choice found something. On JSON.jl 1.x JSON.parse returns a JSON.Object,
which is an AbstractDict but not a Dict — and master's three put! methods are
v::T<:APIModel, v::Dict{String,Any} and v::T<:APIModel, all narrow, so a parsed
template matches none of them and the call is a MethodError. master allows JSON 1
in [compat], so that is a live hazard there rather than a hypothetical. This
branch's v::AbstractDict covers both, and the two cases in the testset are now
deliberately different shapes: a plain Dict for the namespace, a JSON.Object for
the deployment.

Beyond the round trip it pins the things that could break silently: nested
arrays and string maps surviving into the model, the apiVersion coming off the
dict rather than off discovery, and the kind-completion path — a dict with no
"kind", which this branch fills in from the symbol without mutating the caller's
dictionary and without master's unguarded v["kind"] KeyError in exactly the case
the code exists to handle.

Live: 366 assertions against k3s v1.35.4, up from 310.
Found while writing G13's testset: hot_standby.jl scales a deployment with
application/json-patch+json and a Vector of operation dictionaries, where the
live suite only ever patches with a merge-patch dict. OP_BODIES requires a patch
body to be built as the generated Patch type, so whether a bare vector survives
that is the first thing G16 should test.
…16, C8)

Enumerating what JuliaRun and JobLoops actually patch — G16's task — turned up a
break rather than a coverage gap. k8s documents ONE request schema for all five
patch media types: meta.v1.Patch, `type: object`. That is untrue of
application/json-patch+json, whose body is an array of RFC 6902 operations, and
the generated Patch model can only hold an object:

    DecodeError: expected an object while decoding …Patch, got Vector{Dict{String, Any}}

Which is most of the production patch traffic: julia_parallel_scale (every
worker scale up and down), taint_update_patch, julia_update_job, and
hot_standby.jl's deployment scaling. Only the merge-patch callers worked.

The fix is a patch rule, per the standing rule that a document which lies gets
one. The json-patch content schema becomes an array, declared once as a
component (meta.v1.JSONPatch) and referenced: inlining it per operation makes
the generator emit one item type per patch operation — 132 in apps/v1 alone,
+27 KiB — where the shared component emits one, +3.4 KiB. Items stay untyped
objects, since move/copy carry `from` and remove carries no `value`.

OP_BODIES consequently maps media type -> body type instead of carrying one type
and a list of media types, because a PATCH now genuinely has two body types.
That is a change to the registration contract published in C1a, so register!'s
docstring, its validation and test/register.jl's fixture moved with it.

update! normalizes three caller shapes through _patch_payload: a dict or vector
passes through, JSON text is parsed, and a generated model is encoded to its
JSON object first — JuliaRun patches Secrets with a whole desired Secret, and a
model cannot decode into the open Patch struct. That last one was found by the
live test rather than by reading.

Covered offline in test/simpleapi.jl (per-media body types, the nested-vector
taint shape, and that the object model still refuses an array) and pinned for
every patchable kind in every module by a new test/registry.jl gate, which is
what stops the rule silently not applying to some document. Live: single- and
two-operation json-patches, JSON text, a strategic merge patch, and a Secret
patched with a typed model.

Also recorded, not fixed here: C9, a live defect in JobLoops — networkpolicy.jl
sends a whole policy object under json-patch, which a real apiserver answers 422
— and G12a, that Secret.data now decodes to Vector{UInt8} rather than base64
text, so the natural 0.2.x read idiom decodes twice.
The live testsets create and delete in the same block with no finally, so a
failure in the middle leaves objects behind. The next run then fails at put!
with a 409 before reaching whatever actually broke — so the second run reports a
stale error and hides the real one. That happened twice while working G16: a
genuine failure at a two-operation json-patch was followed by a run that aborted
at the first put!, and the fix under test was never exercised.

ensure_absent deletes an object if present and waits for it to be gone;
reset_test_objects runs it over everything a pass creates, before the testsets
start. The namespace delete cascades, which covers the objects inside it.

CI never saw this because kind is a new cluster every run. It only bites local
runs against a long-lived cluster, which is exactly where a failure is being
investigated.
Verified on k3s v1.35.4: registry 5664, register 58, helpers 105, simpleapi 90,
watch recovery 47, live 472. The live count is up from 366 because the two
passes now run to completion rather than aborting mid-testset on leftovers.
K8sReflector watches :Pod with namespace=nothing and a label_selector built by
Kuber's own sel helper (k8s_job_pod_monitoring.jl:66). The suite watched a single
namespace with no selector, so scope fall-through (:cluster then :allns) and
selector handling were covered only as offline resolution.

The test mirrors the reflector rather than approximating it, and covers both
halves of its loop: the initial get that fills the store, and the watch that
maintains it. Two things make it meaningful rather than merely green. A selected
pod is created in TWO namespaces, so a result carrying both proves the read is
all-namespaces rather than luckily single-namespace. And the watch resumes from
the list's resourceVersion, with an unselected pod created BEFORE the selected
one — so seeing the selected event proves the other was filtered rather than
merely late, where a wait-and-hope negative would be flaky.

Found while writing it, and recorded in the doc: put! addresses the request with
ctx.namespace and ignores metadata.namespace on the object, so creating an object
whose metadata names another namespace is a 400. Not a regression — master does
the same and does not even offer the keyword — but it is why the test passes
namespace= explicitly.
The recorded 472 was measured on a run that aborted part way through
'Create/Delete Objects' because earlier failures had left objects on the
cluster. With ensure_absent/reset_test_objects in place the suite runs to
completion at 614.
ReplicaSet, DaemonSet, CronJob, RoleBinding, NetworkPolicy, PersistentVolume
and PersistentVolumeClaim, each through the four paths with distinct schemas:
create, a get once the controller has written a status, a list with the object
still in it, and delete. A get issued straight after put! decodes an empty
status block and checks almost none of the kind's schema, so the status poll is
the point rather than incidental.

Strict response validation checks every kind's schemas independently and two of
the six patch rules were found by submitting a kind for the first time, so a
seventh rule was the plausible outcome here. None was needed: all seven kinds
round-tripped unchanged across four group modules, two of which
(rbac.authorization.k8s.io/v1, networking.k8s.io/v1) had never been reached
live at all.

The fixtures copy the real consumer templates with two deliberate departures.
The RoleBinding names a Role that does not exist rather than reproducing
JuliaRun's ClusterRole/admin grant -- RBAC permits a dangling roleRef, so the
schema is identical and the privilege is not. The NetworkPolicy selects a label
no pod carries rather than JobLoops' empty podSelector, which is deny-all-
ingress for the namespace: inert under kind's CNI, but it would break the rest
of the suite on a cluster whose CNI enforces. Nothing schedules a workload.

Also corrects three stale rows in G6's table -- Secret was already covered by
G16's patch block, Namespace and ConfigMap by G13's -- and records under C4
that generated field names lowercase the JSON name rather than snake_casing it,
which is where this testset spent its debugging.

Live suite 614 -> 718.
One ConfigMap carries all three: labels, annotations and data, no controller
writing to it, and creating it makes the ConfigMap list non-empty, which a
list-shape assertion needs in order to mean anything.

G9 rounds labels and annotations through the server. The assertion that earns
its place is the negative one -- metadata.labels is not an AbstractDict and
indexing it the 0.2.x way is a MethodError, which is the exact shape of the
networkpolicy.jl:114 break.

G11 asserts the list shape consumers actually destructure, and turned up G18:
list items are a different Julia type from the standalone object
(IoK8sApiCoreV1PodListItemsItem, not IoK8sApiCoreV1Pod), on every list kind in
every group module. master's PodList.items was Vector{IoK8sApiCoreV1Pod}, so
this is a regression. The k8s document wraps the element $ref in an allOf with
a sibling "default": {}, which makes it a new schema; fields and nested types
are shared, so reads work and nothing had noticed. isa comparisons do not --
JuliaRun api.jl:1418 is one. Fixing it needs a seventh patch rule and a
regeneration, so the test pins the current behaviour and G18 records the
decision.

G10 found that resource_version= never reaches the server on a non-watch read:
the guard "if !watch || resource_version === nothing" computes the result
without it. master has the identical guard in all four verbs, so it is a shared
limitation rather than something the port broke. The test asserts the trap
instead of the intent -- the same impossible version errors as resourceversion=
(the generated spelling, a real query parameter) and succeeds as
resource_version= (the documented spelling, dropped). The fix is split out as
G17 because get's half cannot be fixed by forwarding at all: k8s declares only
"pretty" on read operations.

Also records that the live assertion total moves with cluster activity, since
Watch Events asserts per observed event -- the totals in this file's table are
not comparable across runs.
K8sReflector wraps Kuber.watch in its own `while true` and relies on watch
returning on a long-poll timeout so it can re-establish from a resourceVersion
it tracked. Watches here carry no deadline and the pump re-watches internally,
so a clean server close never ends the watch -- including one caused by
timeoutseconds, which means there is no server-side way to hand control back
either. The reflector's loop is dead code on this branch regardless.

What the test establishes is that the pattern is still expressible: a stream
processor that leaves its event loop closes the stream through the finally in
watch(streamprocessor, ...), the watch ends, and the caller re-enters with its
own resource_version. Two rounds against the fake apiserver, asserting the
second watch query carries the version the first round's last event reported.

The assertion worth having is the last one: the resumed round issues no list
request. A caller supplying resource_version skips the initial list, so a
consumer keeping its own store pays for full state exactly once -- the useful
half of the same `if !watch || resource_version === nothing` guard that makes
G17 a bug on non-watch reads.

Ticked with the distinction stated in the doc, since "G2 done" would otherwise
read as "the reflector is fine": the box that remains open is porting its loop.

watch_recovery.jl 47 -> 56.
The other re-watch tests assert that a resume happens with the right
resourceVersion. This asserts the property a cache-maintaining consumer
actually depends on: the sequence either side of the seam is exactly what the
server sent.

Two things keep it from restating the resume assertion. The first watch sends a
burst of three events and then closes cleanly, so events are in flight when the
connection ends rather than arriving one per round trip; and the consumer reads
nothing until the seam has demonstrably passed -- it waits on the second watch
request appearing, not on an event -- so the burst has to survive buffered
across the re-watch. Assertions: the exact sequence, allunique, an empty stream
afterwards (a re-delivered frame would be sitting in it), and that the resume
names the last version of the burst rather than the first.

Documented alongside what it does not prove: that no duplicate arrives, only
that Kuber does not manufacture one. Kuber cannot deduplicate -- watch events
carry no identity beyond the object and its version -- so a consumer needing
exactly-once must key on metadata.resourceversion itself, as it would with
client-go. Worth stating because "continuity is tested" invites the stronger
reading.

This closes the watch-contract cluster (G1-G4) apart from the reflector port
under G2 and the deferred G5.

watch_recovery.jl 56 -> 70.
JuliaRun builds Secrets whose data values are raw Vector{UInt8}:
_as_binary_secret (api.jl:203-214) base64-decodes anything that looks base64
before handing it over, so what reaches Kuber is always bytes. The 0.2.x client
base64-encoded them onto the wire because the field is format: byte, and the
1.0 runtime does the same in both directions, so those values survive the port
unchanged. The container does not -- data is an open struct now, so
Secret(; data=bindata) becomes
Secret(; data=SecretData(additional_properties=bindata)).

The test writes bytes that are deliberately not valid UTF-8, so a round trip
that "works" by treating the value as text cannot pass it.

Two behaviours pinned because they are easy to guess wrong:

- stringData is write-only. The apiserver folds it into data and never returns
  it, so a consumer that writes it must not expect to read it back.
- A merge patch merges the map rather than replacing it (RFC 7386): patching
  data with only "token" leaves "binary" and "plain" intact, and only an
  explicit null removes a key. This was asserted the wrong way round first and
  the live run corrected it. update_secret sends the whole desired map, so it
  is unaffected either way -- but a caller who believed the map were replaced
  when it merges would be safe, and one who believed the reverse would silently
  drop every other secret in the object.
…e (G8)

The Namespace half of G8 was already covered by create_delete_from_dicts (G13)
and PersistentVolume by create_delete_more_kinds (G6). Node was left, and Node
is different in kind: no consumer creates one. The monorepo's set_node_label,
set_node_cordon and taint_update_patch all patch an existing node. Creating a
Node object through the API works, but it would test an operation nobody
performs and leave a kubelet-less NotReady node on the cluster for
metrics-server and the scheduler to trip over -- so this patches a real node
and puts it back.

Both consumer shapes:

- a merge patch setting a label, then an explicit null removing it, which pins
  the complement of what G7 found: unmentioned keys survive a merge patch and
  null is the only way to delete one.
- a json-patch whose value is a nested array of dicts (taint_update_patch's
  shape), appending via /spec/taints/- rather than replacing /spec/taints so
  the node's existing taints are untouched. A control-plane node carries one,
  and dropping it would be a live change to the cluster rather than a test.

The taint is PreferNoSchedule and nothing cordons, because the rest of the live
suite schedules pods on that same node.

Reading a Node by name also exercises the :cluster scope fallback for a kind
that is neither Namespace nor PersistentVolume.
characterize_retries.jl pins the exception types the runtime raises, is not in
runtests.jl, and never drives the retry loop. test/retries.jl drives it: a
server that always fails with a chosen status and counts requests, so "retried"
is the difference between one request and several rather than something
inferred from timing. Offline and deterministic -- provoking real load-shedding
from an apiserver is neither reliable nor cheap, and the interesting variable is
the status, not the cluster.

With HTTP.jl's own retry layer switched off so the count reflects Kuber's loop
alone, at max_tries=3: 500/502/503/504 take 4 requests and are retryable;
404/409/422 and 429 take 1 and are not. The watch establish call -- the only
thing in the watch path k8s_retry wraps -- retries and then surfaces.

Writing it turned up two things, both shared with master and both recorded as
decisions rather than changed here:

G19: 429 is not retried. Kubernetes sheds load with 429 plus Retry-After and
client-go retries it; k8s_retryable_codes omits it on both branches, so a
throttled call fails at once where client-go would have absorbed it.

G20: HTTP.jl 2.x retries idempotent requests underneath Kuber, five requests per
Kuber attempt at this pin -- max_tries=1 is ten requests, not two, and
set_retries(count=5) is thirty rather than six, with both backoffs composing.
max_tries is also off by one: it is a retry count, so max_tries=1 means two
attempts, and mutating calls take retries(ctx, true) == 1, which means a put!
whose first attempt 5xxs is retried once. That is not what "only non-mutating
calls retry by default" implies.

The two interact -- fixing G19 means little while HTTP.jl retries 429 anyway,
outside max_tries and without honouring Retry-After -- so they are grouped in
the suggested order.
…(G18, G17)

Patch rule 7. k8s never writes a bare $ref for a property: it wraps it in a
single-element allOf so it can hang a description beside it, because a $ref
with siblings is undefined in OAS 3.0. Read literally that wrapper is a new
schema, so the generator minted a type per use site:

  Pod.spec        was IoK8sApiCoreV1PodSpec2 -- the 2 disambiguating it from
                  the real PodSpec component, which nothing referenced
  Pod.metadata    was IoK8sApiCoreV1PodMetadata, not the shared ObjectMeta
  PodList.items   was Vector{IoK8sApiCoreV1PodListItemsItem}, so
                  `item isa kind_to_type(ctx, :Pod)` was false

The last of those is G18, a regression from master where all three were one
type. JuliaRun/src/kubernetes/api.jl:1418 compares types on an object that came
out of a list.

1290 sites across the 18 documents, in exactly two positions: property schemas
and the items of array properties. Generated types 2252 -> 1098, tree 24 -> 18 MB.

Two things about the rule:

- Scoped to those two positions rather than walked recursively. apiextensions'
  JSONSchemaProps describes JSON Schema itself, so it has properties *named*
  allOf, nullable and items; a recursive walk rewrites that map and silently
  corrupts the CRD document. Verified it survives intact.
- Guarded on shape -- single-element allOf whose element is a bare $ref --
  rather than on the survey that says every allOf looks like that. A future
  spec bump introducing a two-element allOf is left alone to be noticed.

test/registry.jl gates it on type identity across four kinds in three group
modules, plus no ListItemsItem name surviving anywhere. A collapse that
produced an alias per use site would shrink the diff by as much and still be
wrong.

Also G17's list half: list now forwards resource_version to the operation's
resourceversion parameter on the non-watch path. Inside a watch it still means
"resume from here" and is consumed by the pump, which is a different thing.

G17's get half is deliberately NOT done. The apiserver does honour
resourceVersion on a single read -- verified live, an impossible version answers
504 -- so a patch rule would be defensible. It is held back because it would
change which requests Kuber can construct, and its natural failure is a 504 that
blocks for the apiserver's wait, multiplied by a retry budget that G20 shows is
not what it appears (max_tries=1 is ten requests). Cheap to add once G19/G20 are
settled, and pointless before.

Strict generation and strict response validation stayed on. Full suite green
against a live cluster: registry 5694, retries 47, watch recovery 70, live 756.

The performance table in OpenAPIv1TrialResults.md predates this and is marked
stale rather than re-quoted -- half the types can only have improved
precompilation and TTFX, but by how much is unmeasured.
…budget

G19 and G20, done together because neither makes sense alone.

G20. HTTP.jl 2.x retries idempotent requests on a retryable status by default,
underneath k8s_retry -- five requests per Kuber attempt at this pin. So
max_tries bounded nothing (max_tries=1 was ten requests, set_retries(count=5)
was thirty), both backoffs composed, and a mutating call could be retried by a
layer with no notion of which calls mutate. Kuber already had a curated policy
-- a status list, a mutating-vs-not rule, is_retryable as its public face -- and
HTTP.jl's layer silently contradicted all three.

_call_options now merges retry=false into every call, so Kuber's loop is the
only one; set_request_options(ctx; retry=true) hands it back. It goes there
rather than on the context because client_kwargs are passed to the generated
Client constructor, which takes no retry -- putting it there is a MethodError on
the first call.

k8s_delay clamps to max(0, max_tries - 1) delays, so max_tries counts attempts.
Two visible consequences: default_retries=5 is 5 attempts rather than 6, and a
mutating call is 1 attempt rather than 2 -- the contract
set_retries(all_apis=false) always claimed, and the direction that avoids a
duplicate create.

G19. Kubernetes sheds load with 429 plus Retry-After and client-go retries it;
k8s_retryable_codes omitted it, so a throttled call failed at once. Before G20
HTTP.jl retried it anyway, making the observable behaviour "retried, but not by
Kuber, not honouring Retry-After, and not counted by max_tries" -- worse than
either answer. With HTTP.jl's layer off, Kuber's list is the whole story, so 429
joins it.

Retry-After is a floor on the backoff: it only ever lengthens a wait. Scoped to
429 (a 5xx carrying the header should not reprice every retry in the client),
capped at 30s, delta-seconds only -- the HTTP-date form parses to nothing and
falls back to the backoff, which is the safe direction. Honouring it is why
k8s_retry is an explicit loop now: Base.retry takes its delays from an iterator
that never sees the exception.

Docstrings that would otherwise have become lies are updated rather than left:
set_retries' "how many times to retry", k8s_retry's "if max_tries > 1",
set_request_options, and README's retry bullet. characterize_retries.jl is not
in runtests.jl and still prints correctly, but now notes that the loop beneath
it changed.

This unblocks G17's get half, whose objection was a slow 504 multiplied by an
unbounded retry budget.

Full suite green: registry 5694, register! 58, helpers 106, simpleapi 90,
retries 56, watch recovery 70, live 744.
Patch rule 8, and the last of G17.

k8s documents resourceVersion on every list operation and on none of the reads,
but the apiserver honours it on both -- verified live: GET .../configmaps/x?
resourceVersion=0 answers 200, and a version the cluster has never reached
answers 504 "Too large resource version". So a consumer asking for a read "not
older than" a version it already saw had no parameter to send. K8sReflector
does exactly that (K8sReflector.jl:136-141), and that call had never done what
it reads as, on either branch.

Added only to paths ending in {name}: the object read itself, not subresources
like pods/log where a resource version is meaningless. 31 operations in core v1,
none of which declared it already. The parameter goes at operation level; the
path-level name/namespace/pretty are untouched.

`get` forwards resource_version on the non-watch path, as `list` already does.
Inside a watch it still means where to resume from and is consumed by the pump.

Sequenced deliberately after G19/G20. Rules 1-7 make the document describe what
the server already does with requests Kuber already sends; this is the first
that changes which requests Kuber can construct, and its natural failure is a
504 that blocks for the apiserver's wait. That was not worth adding while
max_tries=1 still meant ten requests.

The live assertion is that an impossible version now answers 504 through `get`
-- which is what proves the rule reached the wire rather than only the document
-- plus the reflector's actual shape: read, keep the version, read again not
older than it.

Full suite green: registry 5694, register! 58, helpers 106, simpleapi 90,
retries 56, watch recovery 70, live 752.
Patch rules 7 and 8 halved the generated type count (2252 -> 1098), which made
the numbers in OpenAPIv1TrialResults.md section 2 stale in the branch's favour.
Re-measured on the same cluster and methodology, kept alongside the originals
because the delta is the point:

  precompilation      ~22 s     -> 14.4 s
  using Kuber          0.36 s   -> 0.24 s
  discovery            0.22 s   -> 0.22 s   (unchanged, as I/O should be)
  TTFX first list     ~15-16 s  -> 12.4 s
  steady list (2 pods) 11.5 ms  -> 8.6 ms
  validation share      78 %    -> 72 %
  second group module   1.2 s   -> 1.0-1.3 s
  watch reaction     5.6-11.2ms -> 5.5-10.4 ms, 0 missed
  registry emission   ~100 s    -> 95.4 s

Everything that is compilation got about a third cheaper; nothing that is I/O
moved. TTFX only came down ~3 s, which says the remainder is the validation
engine and the operation rather than the model types -- a PrecompileTools
workload is what would move it.

The steady-state row nearly produced a false result. The first measurement said
116 ms, a tenfold regression, and the cause was the cluster: `default` held 35
pods and the call is dominated by per-item response validation. The original
11.5 ms was taken at 2 pods, so that condition was recreated. The doc now says
the number is meaningless without its item count.

Chasing those 35 pods found the leak that produced them: delete!(ctx, :Job, ...)
sends no propagation policy, so the apiserver orphans the Job's pods -- about
two per run. CI never sees it because kind is fresh each time; a local or shared
cluster accumulates them, and they are not inert, as the 116 ms shows.
propagation_policy="Background" on the Job delete and in ensure_absent. Verified:
the run after this fix left zero pods in `default`, where it would have left two.

Rerunning the whole generation chain also reproduced src/ApiImpl/generated/ and
the patched specs byte-identically to what is committed -- the reproducibility
claim gen/openapi_v1/README.md makes, checked rather than asserted.

Full suite green: registry 5694, register! 58, helpers 106, simpleapi 90,
retries 56, watch recovery 70, live 760.
…sumers (G12, G12a)

The Kuber half of G12 is one testset: a pod carrying both limits and requests,
read back through kuber_props, with Quantity's shape pinned (still a struct with
a single value field, so JuliaRun's string(cpu.value) survives). It also asserts
the negative -- neither map is a dictionary, so `in keys(res)` and `res["cpu"]`
are MethodErrors rather than wrong answers -- and that after patch rule 7
`resources` is the shared ResourceRequirements rather than a positional copy per
container-bearing kind.

Both audits produced consumer fixes, not Kuber fixes.

G12a found one real site. JuliaRunPool.jl:135-136 compares a value read back
from a Secret against the base64-encoded config string. On 0.2.x Secret.data came
back as base64 text and that was correct; here it is a Vector{UInt8} of
plaintext, so the comparison can never hold. The branch exists to skip a
redundant write, so nothing errors -- the image-pull secret is rewritten on every
namespace creation instead. Everything else is clear: JuliaRun's base64decode
sites are kubeconfig parsing, env packing, or the write side, and the monorepo's
kill_k8s/monitoring_loop blobs come from the database, not from a Secret.

G12 found two breaks in clustermgmt.jl. keys(res)/res["cpu"] on an open struct is
the loud one. The quiet one is hasproperty: on 0.2.x an unset field was missing,
on 1.0 every field exists and unset is ABSENT, so hasproperty(resources,
:requests) is always true and container_resource returns ABSENT for a container
declaring only limits -- never reaching its elseif, and the caller's `res ===
nothing` guard does not catch it.

That pattern surfaced the largest unrecorded item in the document, now added to
C2: JuliaRun imports getpropertyat/haspropertyat from OpenAPI.Clients across 49
call sites, and they do not exist in 1.0. Recorded with a recommendation to
export Kuber equivalents built on _field, treating ABSENT and nothing alike --
not implemented, because a new public API is not a routine call. It also moves 49
sites from "rewrite" to "reimport", which changes how C1c should be costed.

Full suite green: registry 5694, register! 58, helpers 106, simpleapi 90,
retries 56, watch recovery 70, live 812.
OpenAPI.jl 1.0 dropped OpenAPI.Clients.getpropertyat/haspropertyat, and JuliaRun
imports them at 49 call sites -- the largest single porting item in
OpenAPIv1ConsumerGaps.md, missed until the G12 audit. These are the
replacements, in src/helpers.jl.

Kept unexported on purpose: they are a shim for consumers porting off 0.2.x
rather than a shape this API wants to encourage, so a call site has to write
Kuber. and stays easy to grep for later. For JuliaRun the change is the import
line, not the 49 uses.

Three decisions:

ABSENT counts as absent. This is the whole reason they exist. 0.x overrode
Base.hasproperty on models to mean "not nothing", so haspropertyat asked a real
question; on 1.0 every field exists, so a handwritten walk answers true for
everything -- the trap that makes JuliaRun's container_resource return ABSENT
instead of falling through to limits. The test pins both sides:
hasproperty(pod, :status) is true while haspropertyat(pod, :status) is false.

A path element may name an open-struct entry, so
getpropertyat(node, :metadata, :labels, "role") reads a label without a separate
kuber_props call. That covers the get(nodelabels, "role", "") sites in
clustermgmt.jl:203-204 as well.

Case is not folded. JuliaRun's calls include :loadBalancer, :nodeName and
:backoffLimit, which are C4 breaks at the same sites. Folding would have fixed
those silently, at the cost of a genuine typo succeeding whenever it happened to
lowercase-match. A wrong name reads as absent, and the test asserts both
directions.

0.x's odd behaviour of returning a Vector{Bool} when a vector is met by a
non-integer path element is kept rather than quietly improved -- the job of a
shim is to be a drop-in.

Full suite green: registry 5694, register! 58, helpers 130, simpleapi 90,
retries 56, watch recovery 70, live 816.
C1b drew the boundary for captured groups -- a group belongs in Kuber when any
user of the API could plausibly have it -- and moved metrics.k8s.io in on it.
The same argument reaches custom.metrics.k8s.io and the documents never followed:
seven passages still said it belongs in JuliaHubK8sApi or "the deployment's own
package", which contradicted C1b in the same file.

It belongs here. The helpers that call it are exported from Kuber and Metrics.md
documents them at length; 0.2.x shipped the group in Kuber too; and the schema is
adapter-independent boilerplate from custom-metrics-apiserver, so what varies per
adapter is metric names, which are path parameters rather than types. Shipping
the helpers here and the group they call elsewhere is the incoherence C1b just
corrected once.

Refines the rule while it is being applied: the test is now prevalence *and*
whether the schema varies with the deployment. Operator CRDs fail the second and
stay out; custom metrics passes both.

That last claim is the load-bearing one, so C5 now says to check it at capture
time rather than assert it -- components.schemas holding only MetricValue,
MetricValueList and shared meta, and the path parameter really being called
compositemetricname, which _positional was fixed for against master's
hand-spliced fragment and never against a served document. A metric name in the
schema would flip the decision back.

Also records that the capture is more reachable than "a cluster this repo cannot
reach": any conformant adapter serves the same document, so the
custom-metrics-apiserver test-adapter, which needs no Prometheus behind it, is
enough.

No code changes. README's bullet was stale in a second way and is fixed with the
rest: it still said the custom-metrics helpers throw and that metrics.k8s.io was
out of trial scope, both untrue since the 2026-08-14 capture.
C5 said what was needed -- one capture of custom.metrics.k8s.io/v1beta1 -- and
left "on a cluster running an adapter" as the whole instruction. Looking for a
recipe turned up two things that change the shape of the step.

JuliaRun already ships prometheus-adapter manifests with custom metrics
(scripts/local/compute/metrics/prometheus/manifests/), which looks like the
obvious source and is not one: the image is
quay.io/coreos/k8s-prometheus-adapter-amd64:v0.5.0, from 2019 and built long
before aggregated API servers served /openapi/v3 at all, and the APIService is
apiregistration.k8s.io/v1beta1, which k8s 1.22 removed. C5 now says so, because
the failure mode is an afternoon spent on a manifest set that cannot produce the
document.

That is also what makes the gate worth stating first. fetch_specs.sh
--from-cluster does one thing, kubectl get --raw /openapi/v3/apis/<gv>, so
whether a cluster can produce the capture is answerable in one command before
anything is deployed -- and the answer is a property of the adapter's age, not
of the command. Verified the jq shape against the local k3s, where it prints
apis/metrics.k8s.io/v1beta1.

Three routes recorded in provenance order: a JuliaHub cluster (the document
production actually serves, blocked on access), a current prometheus-adapter on
the local k3s, and the custom-metrics-apiserver test-adapter, which serves static
values with no Prometheus behind it -- test-adapter-deploy/testing-adapter.yaml,
no published image, and a k3s wrinkle since make test-kind loads into kind.

Second finding, smaller: those same manifests register v1beta1 only, with no
v1beta2 APIService. That is evidence on C5's second checkbox rather than an
answer to it -- a dev-deploy path is not production -- so the box stays open with
the expected answer recorded.
Stood up prometheus-adapter v0.12.0 on the local k3s with no Prometheus behind
it, captured the served OpenAPI v3 document, and took the adapter down again.
The document is kept under gen/openapi_v1/reference-captures/ -- deliberately not
in specs/, which the patch and generate steps glob.

The schemas are exactly what C5 predicted: MetricValue, MetricValueList and
shared meta types, no metric name anywhere. Metric names are discovered at
runtime, which is why the resource list came back empty with no Prometheus while
the document was complete. So the adapter-independence claim holds, and testing
it was still not enough -- the operations are what decide the cost, and they do
not fit:

- No operation carries x-kubernetes-group-version-kind. emit_registry.jl skips
  those, so OPS would get nothing while KIND_TYPES populated normally from the
  schemas, which carry GVK. kind_to_type(:MetricValue) would work and
  list(ctx, :MetricValue, ...) would not resolve -- worse than not shipping.
- Metrics are addressed through {resource}/{name}/{subresource}: three path
  parameters. _positional takes one, and the emitter's plural-to-kind pass keys
  on a literal path segment that a variable cannot supply.

Against that: nothing calls the API. The earlier audit counted references, not
callers. JuliaRun's KubernetesMetrics module is the only code touching custom
metrics *or* metrics.k8s.io, nothing constructs a KubernetesMetricsCtx except a
standalone probe that runtests.jl never includes, and the monorepo has no
reference to either group in any Julia file. metrics.k8s.io stays regardless --
already shipped and live-tested, near-universal, and 0.2.x had it.

Corrects a factual error the capture exposed, in src/simpleapi.jl and in C5:
custom.metrics.k8s.io does not call its path parameter compositemetricname.
That was master's hand-spliced Swagger collapsing three parameters into one, a
fiction that produced the right URL because joining them with / is the composite
string. The _positional relaxation stays -- accepting some other single name is
still right for captured groups -- but it was never what this group needed.

Also records a trap worth knowing for any capture: /openapi/v3 listed v1beta2 as
well, and v1beta2 is not reachable -- no APIService registers it, /apis/... 404s,
and discovery offers v1beta1 alone. An entry in /openapi/v3 means the aggregated
server compiled the version in, not that the cluster serves it. That answers C5's
v1beta2 checkbox too.
The cluster-capture mode wrote its provenance with a plain `>` and listed only
the files of the run that wrote it. So capturing a second group destroyed the
first group's record -- in the one file whose entire job is to remember where a
document came from, and silently, since the surviving record looks perfectly
well-formed. Capturing custom.metrics.k8s.io this way would have erased how
metrics.k8s.io was obtained.

SPECS_CAPTURED is now one record per file -- file, group version path, server,
context, date, checksum -- and a capture replaces the records for the files it
writes while leaving the others alone. Groups captured months apart from
different clusters each keep their own provenance, which the old single header
block could not express anyway: it stamped one server and one date across
whatever happened to be listed.

Two smaller things in the same path:

- A capture went through `kubectl ... | jq . > "$out"`, so the redirect emptied
  the existing document before kubectl was known to have failed. It writes to a
  temp file and moves on success, and says which document it left alone.
- SPECS_DIR overrides the destination directory, so the capture path can be run
  against a throwaway directory instead of the checked-in specs.

The checked-in record for metrics.k8s.io is migrated to the new format by hand,
and verified byte-identical to what a fresh capture now produces.

Exercised against the local k3s: re-capturing a group replaces its own record,
capturing a different group leaves the first intact (the bug), two groups in one
invocation give two records, and a failed capture leaves both the document and
the provenance file untouched with no temp files behind.
The merge identified a record by its file: line and skipped anything without
one, so a hand-edit that split a record -- a stray blank line in the middle --
lost the half that did not carry the name, silently. That is the same failure
the merge was written to prevent, just at a smaller scale, and hand-editing is
not hypothetical: the existing record was migrated to this format by hand.

Unrecognized blocks are now kept verbatim with a line to stderr. A mangled file
stays mangled and visible rather than quietly getting shorter.

Also states C5's emitter claim as what it is. The captured operations carry
neither x-kubernetes-group-version-kind nor x-kubernetes-action, and ops() needs
both -- the first pass to learn a resource's kind, the second to drop anything
it did not learn and anything without an action verb. That is read off the
document and emit_registry.jl:245-262 rather than measured by running the chain,
and the document now says so.
G5 asked whether a long-lived watch is testable in CI and had stood deferred on
"probably not". The premise was wrong: what makes a watch long-lived is the
apiserver's own timer -- min-request-timeout defaults to 1800s and the handler
randomizes within [1800, 3600) -- so an unbounded watch is closed at 30 to 60
minutes, and guaranteeing one close means an hour per matrix entry. But the hour
of waiting is not the mechanism. The close is, and a close is producible in
seconds: timeoutseconds on the request ends a watch with exactly the same clean
close (verified against a live cluster), and min-request-timeout is settable on a
kind cluster through kubeadmConfigPatches.

So the item splits:

- G5a, CI-sized: a server-ended watch re-establishes and loses nothing,
  allowwatchbookmarks produces BOOKMARK events that do not disturb the stream or
  the tracked resourceVersion, and an expired resourceVersion resyncs. The first
  and third are widenings -- watch_recovery.jl covers them against the fake
  apiserver, and what is untested is that a real one behaves the same. BOOKMARK
  is not: nothing exercises it anywhere, Kuber never asks for bookmarks, and a
  bookmark carries an object holding only a resourceVersion, a shape that has
  never been through KuberEvent. That leg is where this is most likely to find
  something.

- G5b, manual probe: descriptor and memory growth over hours, a load balancer
  dropping an idle connection, an apiserver rollout mid-watch, HTTP/2 GOAWAY.
  Real wall time or real infrastructure, with watch_latency.jl as the precedent
  for how such a probe is carried. The LB idle timeout is worth calling out --
  60 to 350 seconds on the common cloud balancers, the failure JuliaRun is most
  likely to meet in production, and the one a kind cluster cannot produce at all
  because there is no intermediary in front of it.
long_lived_watch in test/runtests.jl, about twelve seconds of wall time for what
G5 had recorded as an hour-long test nobody could run in CI. timeoutseconds on
the request ends a watch with exactly the clean close the apiserver's own timer
produces, so three server-initiated closes cost ten seconds and exercise the same
path. The kind kubeadmConfigPatches route for min-request-timeout turned out not
to be needed at all.

Three legs, all of them already covered against watch_recovery.jl's fake
apiserver -- which is the point, since what was untested is that a real server
ends a watch the way the fake does:

- pods created either side of a server-initiated close each arrive exactly once,
  so nothing is dropped across the gap and nothing replayed after it
- BOOKMARK events arrive when asked for and disturb neither the stream nor the
  tracked resourceVersion. Nothing exercised these anywhere before: Kuber never
  sets allowwatchbookmarks, and runtests.jl only asserted a bookmark would be
  tolerated if one turned up
- resuming from resourceVersion=1 provokes the in-stream ERROR/410 immediately,
  and the resync frame that follows carries complete current state

Nothing broke, and one rule got confirmed on a payload it had never seen. A
bookmark's object is the watched kind carrying metadata.resourceVersion and, for
a Pod, spec.containers as an explicit null -- a required array property. It
decodes only because patch rule 2 makes array properties nullable, the
Go-nil-slice rule, which until now was exercised against list and read payloads
rather than watch frames. The test asserts that null rather than stepping over
it, so a regression in the rule fails here too.

Live suite 816 -> 883 assertions; full suite green.
The new testset passed locally and failed on kind: one pod's ADDED never
arrived, and only the first of the three, which is the shape of a race rather
than a broken watch.

watch(ctx, O, stream) takes no resource_version, so it lists internally to learn
where to resume and discards that list. An object created between the caller's
watch call and that internal list is inside the list, is thrown away with it, and
is never announced. Locally the create lost that race; on a slower cluster it
won. The test now seeds the version from a get of its own -- the same
list-then-watch shape K8sReflector and watch_selector_all_namespaces already use
-- which closes the window rather than widening a timeout around it.

Not a defect: the form is documented as events-only. But "no initial state" and
"a silent hole at establish time" are different promises and only the first was
written down, so README says it now, next to the events-only form, with the
remedy.

Live suite 898 assertions, full suite green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant