Skip to content

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

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

Trial: rebuild Kuber.jl on OpenAPI.jl 1.0#69
tanmaykm wants to merge 11 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 11 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.
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