Prepared: 2026-04-27
PR: kubernetes/kube-openapi#590
Reviewer question: "go-openapi has some reputation of changing semantics without notification by accident. As we use it in our CRD validation there is risk that we break our API (we have forked the go-openapi validator nowadays, so risk is lower than in the past, but worth a check anyway)."
This PR makes four changes: (1) upgrades go-openapi/swag v0.23.0 → v0.25.4, (2) bumps the go directive from 1.23.0 to 1.24.0, (3) upgrades go.yaml.in/yaml/v3 v3.0.3 → v3.0.4, and (4) fixes pre-existing go vet format-string warnings. The analysis below is based on multi-agent investigation, independent code inspection, and empirical testing.
Bottom line: The PR is safe to merge, but not for the reasons initially stated. The yaml/v3 bump is a pure metadata change. The swag upgrade removes mailru/easyjson and josharian/intern from kube-openapi's build dependency path (they remain in the module graph via transitive edges). One intentional behavioral change in IsFloat64AJSONInteger is present and confirmed — it switches from truncation-based comparison to math.Round-based nearest-integer comparison, changing the epsilon tolerance band symmetrically for both positive and negative values: 3.9999999999 and -3.9999999999 now classify as integers (previously did not), while 1.000000001 no longer does (previously did). The key risk point is already moot for Kubernetes master: Kubernetes currently vendors go-openapi/swag v0.25.4 directly, and the vendored kube-openapi source already imports that version at the CRD validation callsite — the IsFloat64AJSONInteger behavioral change is therefore already live in Kubernetes CRD validation today. PR #590's impact is primarily on kube-openapi's own module metadata, standalone consumers, and the go vet fixes. The go 1.24.0 directive is a real minimum-version floor for non-Kubernetes standalone consumers; Kubernetes master is already on Go 1.26.0 and is unaffected.
swag v0.25.4 was a major structural refactoring. What was a single Go package became a monorepo with 11 independent Go modules:
| Sub-module | Content | Used by kube-openapi |
|---|---|---|
swag/conv |
Type conversion utilities (ConvertInt32, IsFloat64AJSONInteger, etc.) |
YES — validation |
swag/jsonname |
JSON field name inference from struct tags | YES — spec Schema.UnmarshalJSON |
swag/jsonutils |
JSON concat, read/write, ordered maps | YES — spec3, validation/spec |
swag/mangling |
Go name mangling (ToGoName, ToFileName, etc.) |
NO |
swag/yamlutils |
YAML-to-JSON conversion | Indirect (via loading) |
swag/loading |
HTTP/file loading | Indirect |
swag/stringutils |
String search utilities | Test only |
swag/typeutils |
Nil checking, zero-value detection | Indirect |
swag/netutils |
Host/port utilities | NO |
swag/fileutils |
File utilities | NO |
swag/cmdutils |
CLI utilities | NO |
The root package (github.com/go-openapi/swag) now contains only *_iface.go files plus doc.go and tests — the non-test compatibility symbols are thin shims that forward every deprecated symbol to the appropriate sub-module. Implementations have moved entirely to sub-modules. All callers of swag.ConcatJSON, swag.IsFloat64AJSONInteger, etc. continue to compile without changes.
Verification: Every symbol used by kube-openapi (30 files, 30 import statements) was traced to its new location. All are re-exported correctly in the root shim. No symbol was dropped.
| Dependency | Removed from | Status in module graph |
|---|---|---|
github.com/mailru/easyjson v0.7.7 |
go.mod direct requirement; go list -deps ./... build path; h1 hash in go.sum |
Still present in go list -m all and go.sum go.mod hash, via transitive module graph edges (e.g. go-openapi/jsonpointer still references it) |
github.com/josharian/intern v1.0.0 |
Same as above | Same as above |
Precision matters here: the correct claim is "removed from kube-openapi's build and package dependency path." Running go list -deps ./... on the PR branch does not include easyjson or intern; no compiled binary or object file from those packages is included. However, go list -m all still lists both modules and go.sum retains their go.mod hashes (not their source h1 hashes). This distinction is important: the supply-chain concern around easyjson's unmaintained state is addressed at the binary level; the module graph entry is a passive artefact of transitive go.mod references.
mailru/easyjson is effectively unmaintained (last real commit 2023, no active maintainer). Its removal from the build path is a genuine supply-chain improvement.
The sub-module split adds 11 new // indirect entries to go.mod. These are all internal to the swag refactoring — they have no transitive dependencies beyond go.yaml.in/yaml/v3 and the Go standard library. No new third-party risk surfaces.
Notable: go-openapi/testify/v2 and go-openapi/testify/enable/yaml/v2 appear in go.sum but are test-only fixtures for the swag sub-modules' own test infrastructure. They are not pulled into kube-openapi's binary.
Finding: This is a no-op. The entire diff between v3.0.3 and v3.0.4 is one line in go.mod:
-go 1.22
+go 1.16Zero .go source files changed. This was a compatibility fix to broaden which Go toolchains can build the module. There is no change to the YAML parser, encoder, decoder, or any behavior path. Full risk assessment: none.
Background: go.yaml.in/yaml/v3 is the actively maintained successor to the unmaintained gopkg.in/yaml.v3. It is maintained by the YAML org (including Davanum Srinivas, Stefan Prodan, and others). Kubernetes switched to it intentionally.
Files changed: openapi.go, proto_models.go
Nature: Go vet correction (SA1006/printf lint)
// Before (all 6 occurrences)
c.reportError(err.Error())
// After
c.reportError("%v", err)reportError has the signature reportError(format string, args ...interface{}). Passing err.Error() as the format string is a go vet printf violation — if the error string contains %, the output would be garbled. The fix is correct and functionally equivalent for any error string not containing % characters.
Behavioral impact: None in practice. The errors come from getMapElementRelationship and getListElementRelationship which parse x-kubernetes-* extension annotations. These error strings do not contain % characters. For a pathological error message containing %v or %s, the new code correctly passes it verbatim while the old code would misformat it.
Caller impact: reportError appends to c.errorMessages, returned by ToSchema* functions. No downstream code is known to string-match on these specific error messages.
// Before
strippedErrors = append(strippedErrors, fmt.Errorf(strings.TrimPrefix(e.Error(), "IMPORTANT!")))
// After
strippedErrors = append(strippedErrors, fmt.Errorf("%s", strings.TrimPrefix(e.Error(), "IMPORTANT!")))Same pattern: go vet fix. The IMPORTANT! prefix is added by the composite validator internally. These strings do not contain % characters in any known code path.
This is the heart of the reviewer's concern. The analysis covers every function used by kube-openapi.
Old architecture (v0.23.0):
func WriteJSON(data interface{}) ([]byte, error) {
if d, ok := data.(ejMarshaler); ok { // easyjson fast path
jw := new(jwriter.Writer)
d.MarshalEasyJSON(jw)
return jw.BuildBytes()
}
if d, ok := data.(json.Marshaler); ok {
return d.MarshalJSON()
}
return json.Marshal(data)
}New architecture (v0.25.4):
func WriteJSON(value any) ([]byte, error) {
if orderedMap, isOrdered := value.(ifaces.Ordered); isOrdered {
orderedMarshaler := adapters.OrderedMarshalAdapterFor(orderedMap)
if orderedMarshaler != nil {
defer orderedMarshaler.Redeem()
return orderedMarshaler.OrderedMarshal(orderedMap)
}
}
marshaler := adapters.MarshalAdapterFor(value)
if marshaler != nil {
defer marshaler.Redeem()
return marshaler.Marshal(value)
}
return json.Marshal(value)
}The new design uses an adapter pattern with a global registry (adapters.Registry). The default adapter is the stdlib (encoding/json). The easyjson adapter is now opt-in and must be explicitly registered at runtime — it is not registered by default and kube-openapi does not register it.
Critical question: Does kube-openapi have any types that implement ejMarshaler (the easyjson interface)? Answer: No. kube-openapi never ran easyjson code generation. The old fast path was never taken for any kube-openapi type. The effective behavior for all kube-openapi types was always the json.Marshal fallback. The new code reaches the same code path.
JSONMapSlice marshaling — real behavioral changes, but irrelevant to kube-openapi's CRD path:
Empirical testing confirms the following differences between v0.23.0 and v0.25.4:
| Scenario | v0.23.0 result | v0.25.4 result |
|---|---|---|
json.Marshal(JSONMapSlice(nil)) |
{} |
null |
json.Marshal(JSONMapSlice{}) |
{} |
{} |
json.Marshal(JSONMapSlice{{Key:"x", Value:JSONMapSlice(nil)}}) |
{"x":{}} |
{"x":null} |
json.Unmarshal([]byte("null"), &prepopulatedSlice) |
stale entries preserved | slice cleared to nil |
The mechanism: v0.23.0 JSONMapSlice.MarshalJSON used jwriter.Writer{Flags: jwriter.NilMapAsEmpty | NilSliceAsEmpty} which serialized nil as {}. v0.25.4's stdlib OrderedMarshal calls typeutils.IsNil(value) and returns "null" for a nil JSONMapSlice.
The unmarshal discrepancy is significant in isolation: the old UnmarshalEasyJSON silently preserved stale entries when receiving null; the new OrderedUnmarshal correctly clears to nil.
Why this does not matter for kube-openapi's CRD validation path: kube-openapi does not directly construct, marshal, or unmarshal JSONMapSlice values in its CRD validation or schema conversion code. JSONMapSlice is an internal type used by BytesToYAMLDoc/YAMLToJSON to preserve key ordering during YAML→JSON conversion; the resulting json.RawMessage is what kube-openapi operates on. A YAML document will never produce a nil JSONMapSlice at the top level (it would parse as an absent or empty mapping). The stale-unmarshal issue is unreachable from kube-openapi's usage patterns.
Ordering preservation: Both versions preserve key order in JSONMapSlice for non-nil inputs. The stdlib adapter's OrderedMarshal iterates via Go 1.23's iter.Seq2 from OrderedItems(). For the same non-nil content, output is functionally equivalent.
Risk: None for kube-openapi's CRD path. Real but contained behavioral change in JSONMapSlice itself — relevant only to direct consumers of that type.
ConcatJSON is used extensively (17 times in pkg/spec3/, 13 times in pkg/validation/spec/) for merging JSON objects during spec serialization.
v0.23.0:
if len(b) < 3 { // yep empty but also the last one...
if i == last && a > 0 {
if err := buf.WriteByte(closing); err != nil {
log.Println(err)
}
}
continue
}v0.25.4 (jsonutils/concat.go):
const minLengthIfNotEmpty = 3
if len(b) < minLengthIfNotEmpty {
if i == last && a > 0 {
_ = buf.WriteByte(closing) // never returns err != nil
}
continue
}The core merging logic is identical. The new version:
- Names the constant
minLengthIfNotEmpty(documentation improvement) - Suppresses the
log.Println(err)call becausebytes.Buffer.WriteBytenever returns an error (the log call was misleading) - Suppresses
buf.Writeerror returns for the same reason
One edge-case difference — non-container blobs: When all inputs are non-container bytes (neither {...} objects nor [...] arrays), the old version can return a buffer containing a null byte ("\x00") due to how opening is left at its zero value and written. The new version returns an empty byte slice in the same scenario. This is a bug fix in v0.25.4.
Empirically confirmed:
OLD: ConcatJSON([]byte("hello"), []byte("world")) → "h\x00" (garbage + null byte)
NEW: ConcatJSON([]byte("hello"), []byte("world")) → "" (empty, correct)
Risk: None for kube-openapi. All kube-openapi call sites pass valid JSON object fragments ({...}). The non-container bug path is unreachable from spec serialization code. The fix is strictly an improvement.
This function is used in two places in kube-openapi's CRD validation:
pkg/validation/validate/values.go:196—MultipleOfvalidatorpkg/validation/validate/type.go:157—isFloatInttype coercion check
Old algorithm (v0.23.0):
func IsFloat64AJSONInteger(f float64) bool {
if math.IsNaN(f) || math.IsInf(f, 0) || f < minJSONFloat || f > maxJSONFloat {
return false
}
fa := math.Abs(f)
g := float64(uint64(f)) // <-- truncation, undefined behavior for negative numbers
ga := math.Abs(g)
diff := math.Abs(f - g)
switch {
case f == g:
return true
case f == float64(int64(f)) || f == float64(uint64(f)):
return true
case f == 0 || g == 0 || diff < math.SmallestNonzeroFloat64:
return diff < (epsilon * math.SmallestNonzeroFloat64)
}
return diff/math.Min(fa+ga, math.MaxFloat64) < epsilon
}New algorithm (v0.25.4 conv/):
func IsFloat64AJSONInteger(f float64) bool {
if math.IsNaN(f) || math.IsInf(f, 0) || f < minJSONFloat || f > maxJSONFloat {
return false
}
rounded := math.Round(f) // <-- rounds to nearest integer
if f == rounded {
return true
}
if rounded == 0 {
return false
}
diff := math.Abs(f - rounded)
if diff == 0 {
return true
}
return diff < epsilon*math.Abs(rounded) // relative tolerance from rounded value
}Both algorithms use epsilon = 1e-9. The key difference is the reference point for relative error:
- Old: reference =
float64(uint64(f))(truncation toward zero). Relative tolerance ≈diff / (2 * |f|) - New: reference =
math.Round(f)(nearest integer). Relative tolerance ≈diff / |rounded|
The new algorithm is algorithmically cleaner and fixes a latent bug: float64(uint64(f)) for negative f is implementation-defined behavior in Go. The old code avoided crashing because negative exact integers were caught by the "optimistic case" check (f == float64(int64(f))), but the handling of near-negative-integers was architecturally fragile.
Empirically verified divergences (inputs are the quotient passed to IsFloat64AJSONInteger, not the user's value):
| Quotient passed to fn | How it arises | OLD result | NEW result | Direction |
|---|---|---|---|---|
3.9999999999 (= 4.0 - 1e-10) |
multipleOf:1, user value 3.9999999999 |
false (not integer) | true (integer) | Looser |
3.999999999 (= 4.0 - 1e-9) |
multipleOf:1, user value 3.999999999 |
false | true | Looser |
1.000000001 (= 1.0 + 1e-9) |
multipleOf:1, user value 1.0 + 1e-9 |
true (integer) | false (not integer) | Stricter |
999.9999999 |
multipleOf:0.001, user value 0.9999999999 → quotient = 1/0.001 * 0.9999999999 |
false | true | Looser |
4.9999999998 |
multipleOf:0.5, user value 2.4999999999 → quotient = 1/0.5 * 2.4999999999 |
false | true | Looser |
Note: The MultipleOf function computes mult = 1/factor * value for factors < 1, so the value passed into IsFloat64AJSONInteger is the quotient, not the user's original value. A user value of 2.4999999999 with multipleOf: 0.5 produces quotient 4.9999999998, not 2.4999999999.
Boundary analysis — symmetric around nearest integer (positive and negative):
The correct characterization is not "slightly more permissive near positive integers." The new algorithm changes the tolerance band for all values within epsilon * |nearest integer| of that integer, including negative values:
| Quotient | OLD result | NEW result |
|---|---|---|
0.999999999 |
false | true |
3.9999999999 |
false | true |
-3.9999999999 |
false | true |
-4.0000000001 |
false | true |
1.000000001 |
true | false |
The behavior change is symmetric around the nearest integer in both directions. Old algorithm was asymmetric because float64(uint64(f)) truncates toward zero — for 3.9999999999 it produced reference 3.0 (wrong direction). New math.Round uses 4.0 (correct nearest integer). The tightening at 1.000000001 is the flip side: old algorithm's truncation produced reference 1.0 (correct), making the old code happen to be right by accident; new algorithm also uses 1.0, but the relative tolerance formula differs slightly, placing 1.000000001 (= 1.0 + 1e-9, exactly at epsilon) just outside the band.
Impact on CRD multipleOf validation:
MultipleOf computes mult = 1/factor * value (for factors < 1), then calls IsFloat64AJSONInteger(mult). Returning true accepts the value; false produces a "not a multiple of X" error.
Most practical cases are unaffected (integer multiplications produce exact values). The divergences appear only when floating-point arithmetic produces a quotient within epsilon * |nearest integer| of that integer but outside the old truncation-based tolerance. This is a narrow but real range.
Real-world implications:
- CRDs with small float
multipleOf(e.g.,0.001,0.0001): Quotients like999.9999999(previously rejected) are now accepted. More correct behavior. - CRDs with
multipleOf: 1: Values like3.9999999999(within floating-point precision of4.0) are now accepted. This is correct. - Negative-value CRD fields: Same symmetric behavior —
-3.9999999999is now accepted as a multiple of 1. Previously rejected by the old asymmetric algorithm. - Edge case tightening at
1.000000001: Old code accepted this, new code does not. Represents a value exactly at the epsilon boundary — old code was arguably wrong to accept it.
Risk: Low but real. The change is intentional (swag PR #132, August 2025: "improve equality comparison for floats, ~14% faster"). The net direction is toward more correct floating-point handling for both positive and negative boundary values. No existing kube-openapi tests fail. Operators relying on the old algorithm's specific rejection of sub-epsilon boundary values might observe previously-rejected values now accepted — but those rejections were not semantically meaningful.
DefaultJSONNameProvider is used in pkg/validation/spec/schema.go (lines 574, 619) to get JSON names for struct fields, used to differentiate known fields from extension fields during schema unmarshaling.
The new jsonname sub-module preserves the same mutex-based NameProvider with identical buildnameIndex logic using reflect. The type is now a type alias (type NameProvider = jsonname.NameProvider), not a re-declaration. The DefaultJSONNameProvider is now an exported variable in the root shim pointing to jsonname.DefaultJSONNameProvider.
Subtle change: In v0.23.0, DefaultJSONNameProvider was initialized by NewNameProvider() at package init. In v0.25.4, it is jsonname.DefaultJSONNameProvider, which is also initialized at package init in the sub-module. The value is pre-populated and shared across all callers. Behavior is identical.
Risk: None.
These functions are used in pkg/validation/validate/values.go for range validation:
case integerFormatInt32:
_, errVal = swag.ConvertInt32(stringRep)
case integerFormatUint32:
_, errVal = swag.ConvertUint32(stringRep)In v0.25.4, these delegate to generic functions via the conv sub-module:
func ConvertInt32(str string) (int32, error) { return conv.ConvertInteger[int32](str) }The conv.ConvertInteger[int32] uses strconv.ParseInt(str, 10, 64) and bounds-checks against the type's min/max. The underlying strconv.ParseInt behavior is identical. Error messages are identical.
Risk: None.
These use strconv.FormatUint, strconv.FormatInt, strconv.FormatFloat in both versions. The wrappers changed from direct calls to generics (conv.FormatInteger, conv.FormatUinteger, conv.FormatFloat) but produce identical output.
Risk: None.
swag.ToDynamicJSON(data) is called in pkg/validation/validate/schema.go:150 to convert typed values (e.g., strfmt.DateTime) to dynamic JSON before validation.
Old behavior: json.Marshal(data) → json.Unmarshal into interface{}.
New behavior: Routes through adapter pattern → json.Marshal → ReadJSON → json.Unmarshal.
For all types in kube-openapi (none implement ejMarshaler), both paths call json.Marshal and json.Unmarshal. Output is identical.
Risk: None.
These are not called directly by kube-openapi production code (only via loading sub-module). However, the new yamlutils sub-module introduces a sentinel error pattern:
// Old: plain error
return nil, errors.New("only YAML documents that are objects are supported")
// New: error wrapped with ErrYAML sentinel
return nil, fmt.Errorf("only YAML documents that are objects are supported: %w", ErrYAML)And some errors are double-wrapped using Go 1.20's multi-error syntax:
return nil, fmt.Errorf("unable to decode YAML sequence value: %w: %w", err, ErrYAML)This changes the behavior of errors.Is(err, yamlutils.ErrYAML) — callers can now use this sentinel for structured error checking. The error message strings still contain the same human-readable text.
Impact on kube-openapi: kube-openapi does not call errors.Is or errors.As on errors from swag's YAML functions in any production code path examined. The error message text (for human display) is unchanged.
Risk: None for kube-openapi. Low for downstream consumers who string-match on specific YAML error messages from swag.
kube-openapi does not use any name mangling functions (swag.ToGoName, swag.ToFileName, swag.ToJSONName, swag.Camelize, swag.GoNamePrefixFunc, swag.AddInitialisms). Confirmed by grep with no results. These are used in go-swagger for code generation. No risk to kube-openapi.
Importing github.com/go-openapi/swag (the root shim package) at v0.25.4 causes Go to compile all 11 sub-modules into the binary, even if kube-openapi only calls a few exported symbols. Confirmed by examining *_iface.go files: each shim file imports its corresponding sub-module.
Init-time side effect audit: Of all 11 sub-modules, only jsonutils/concat.go has a package-level init() function. It initializes a small lookup map (closerAt) mapping { → } and [ → ]. There is no I/O, no registration of global state that could conflict, no goroutine launch, and no dependency on environment variables. This init is benign and idempotent.
JSON adapter registry: jsonutils has a mutable global adapter registry (adapters.Registry). The default adapter is encoding/json (stdlib). The easyjson adapter is opt-in and requires an explicit adapters.Registry.Register(...) call. kube-openapi does not import jsonutils/adapters, does not call Register, and has no code path that mutates this global. Any future dependency that calls Register at init time would affect all callers globally — a theoretical risk, not a current one.
Risk: None currently. The init side effects are limited to a 2-entry map. The adapter registry mutation risk is theoretical and would require an explicit code change to manifest.
The go directive in go.mod changes from 1.23.0 to 1.24.0. This is required by the swag sub-modules — they use iter.Seq2 (Go 1.23) and other features that mandate go 1.24 in their own go.mod files.
The go directive in a module's go.mod affects:
- Toolchain minimum for the module itself: Anyone building kube-openapi directly needs Go ≥ 1.24.
- Downstream consumer behavior: When Kubernetes vendors kube-openapi, the vendor toolchain only needs to meet the max
godirective it encounters. Kubernetes itself will likely declarego 1.24+by the time this is merged. - Go vet checks enabled: Go 1.24 enables stricter
go vetanalyzers (notably theprintfcheck that surfaced the format-string fixes in this PR). - New language features:
iter.Seq2(range-over-function) requiresgo 1.23. The swag sub-modules use it in theirJSONMapSliceimplementation.
When Kubernetes runs go mod vendor against a kube-openapi that requires go 1.24.0, the vendor directory will contain the kube-openapi source compiled with whatever toolchain Kubernetes itself uses. The go directive in a vendored dependency's go.mod is informational for go mod resolution but does not require the consumer to change their own go directive.
Kubernetes master is already on Go 1.26.0 (as of early 2026), so the 1.24.0 floor is a strict non-issue for the Kubernetes build. The bump matters only for standalone kube-openapi consumers — anyone running go build against kube-openapi directly (not via Kubernetes vendoring) needs Go ≥ 1.24.0 installed.
Risk: None for Kubernetes; Low/administrative for standalone consumers. Kubernetes is on 1.26.0, well above the new floor.
The most important finding in this entire analysis: The IsFloat64AJSONInteger behavioral change is already active in current Kubernetes master CRD validation. Here is why:
- Kubernetes master
go.modlistsgithub.com/go-openapi/swag v0.25.4 // indirect(required by another dependency, selected by MVS). - When Kubernetes runs
go mod vendor, Go's flat vendoring puts one copy ofgo-openapi/swagintovendor/— the MVS-selected version, which is v0.25.4. Thevendor/modules.txtentry reads## explicit; go 1.24.0. - The vendored kube-openapi source (
v0.0.0-20260317180543-43fb72c5454a, March 17, 2026) importsgithub.com/go-openapi/swagat the CRD validation callsites (pkg/validation/validate/values.go,type.go). Those imports resolve to the single vendored copy — v0.25.4. - Therefore
swag.IsFloat64AJSONIntegerused by CRDmultipleOfvalidation in Kubernetes today is already the newmath.Round-based implementation.
This means PR #590 has zero incremental behavioral risk for Kubernetes. It only updates kube-openapi's own go.mod to declare the swag version it already resolves to in the Kubernetes vendor tree. The IsFloat64AJSONInteger behavior change happened when Kubernetes last bumped its vendor snapshot to include swag v0.25.4 — not when PR #590 merges.
The reviewer's comment references "we have forked the go-openapi validator." This refers to pkg/validation/ in kube-openapi, which is a fork of github.com/go-openapi/validate. The fork was done to allow kube-openapi to evolve independently from the upstream validator and to add Kubernetes-specific validation logic.
The key paths in the forked validator that use swag:
pkg/validation/validate/values.go→swag.IsFloat64AJSONInteger(MultipleOf),swag.FormatUint64/Int64/Float64,swag.ConvertInt32/Uint32/Int64/Uint64pkg/validation/validate/type.go→swag.IsFloat64AJSONInteger(isFloatInt type coercion)pkg/validation/validate/schema.go→swag.ToDynamicJSONpkg/validation/spec/schema.go→swag.ConcatJSON,swag.DefaultJSONNameProvider.GetJSONNames
Since kube-openapi forked the validator, changes to the upstream go-openapi/validate package no longer automatically affect Kubernetes. The risk window is narrowly limited to the specific swag functions listed above.
The CRD validation flow in Kubernetes:
apiextensions-apiserver → CRD webhook validation
→ k8s.io/kube-openapi/pkg/validation/validate
→ swag.IsFloat64AJSONInteger (for multipleOf checks)
→ swag.ConvertInt32/Uint32 etc. (for range checks)
The multipleOf constraint in CRDs is relatively uncommon (most CRDs use integer types or string patterns). The floating-point precision boundary cases that diverge between old and new algorithm require the input value to fall within epsilon * |rounded| of an integer but outside the old algorithm's tolerance band. This is an extremely narrow range.
The swag go.mod includes:
retract v0.24.0 // bad tagging of the main module: superseded by v0.24.1
This explains the version gap from v0.23.0 to v0.25.4: the refactoring was partially tagged as v0.24.0 (wrong tag on the wrong commit in the monorepo structure), then corrected to v0.24.1, and then released as v0.25.x after the sub-module split stabilized. The retraction is for a tagging error, not for a behavioral bug.
| Functionality | Test location | Coverage |
|---|---|---|
MultipleOf validation |
values_test.go:132-178, 329-371 |
Tests with integer factors, not floating-point near-boundary |
IsValueValidAgainstRange |
values_test.go:376-415 |
ConvertInt32/Uint32 overflow paths |
ConcatJSON |
Exercised by all spec marshal/unmarshal tests | Comprehensive |
keepRelevantErrors |
result_test.go:183-191 |
Basic correctness |
| Schema validation | pkg/validation/validate/schema_test.go |
Broad, including JSON Schema Test Suite (32 fixture files) |
| JSON Schema Test Suite | pkg/validation/validate/fixtures/jsonschema_suite/ |
Industry-standard suite |
Verification performed on PR #590 branch:
go test ./... PASS (all packages)
go test ./... -count=1 -timeout 120s PASS
cd test/integration && go test ./... PASS
go test -race ./pkg/validation/validate/... PASS
go test -race ./pkg/validation/spec/... PASS
go test -race ./pkg/spec3/... PASS
go test -race ./pkg/schemaconv/... PASS
go vet ./pkg/validation/validate/... ./pkg/schemaconv/... PASS
go vet ./... FAIL (pre-existing failures in
pkg/internal/third_party/
go-json-experiment/json only —
not introduced by this PR)
Race tests pass for all packages with CRD-validation-relevant swag usage.
-
No test for
multipleOfwith floating-point boundary values. The existingMultipleOftests use whole-number factors (e.g.,multipleOf: 5). There is no test that would catch theIsFloat64AJSONIntegerdivergence for values like3.9999999999. The swag sub-module's ownconvpackage tests do cover this, but they're not run as part of kube-openapi's test suite. -
No test for
c.reportErrorwith%-containing error messages inpkg/schemaconv. Theopenapi_test.goandsmd_test.gofiles test happy paths only. An error fromgetMapElementRelationship/getListElementRelationshipwith a%-containing message would expose the difference — but no such message exists in practice. -
No test for
typeValidator.isFloatIntwith float values near integer boundaries. TheisFloatIntpath (line 157 oftype.go) allows anumber-typed field to acceptintegervalues. TheIsFloat64AJSONIntegerchange could affect this type coercion in edge cases.
| Change | Affected Code | Kubernetes CRD Impact | Risk Level | Mitigations Available |
|---|---|---|---|---|
| IsFloat64AJSONInteger algorithm | multipleOf, isFloatInt | Nearest-integer epsilon behavior changed (symmetric, pos+neg); already live in k8s master | Low | Already active; add boundary tests as follow-up |
| easyjson removal from WriteJSON/ReadJSON | ToDynamicJSON, spec marshaling | None — kube-openapi had no easyjson types | None | — |
| ConcatJSON implementation move | All JSON object merging | None — output identical | None | — |
| JSONMapSlice marshaling architecture | YAML-to-JSON conversion | None — output identical for typical OpenAPI content | None | — |
| Error sentinel wrapping in yamlutils | YAML error handling | None — kube-openapi doesn't inspect YAML errors | None | — |
| go.yaml.in/yaml/v3 v3.0.3→v3.0.4 | All YAML parsing | None — zero source changes | None | — |
| Go directive 1.23.0→1.24.0 | Build toolchain | None for Kubernetes (already on Go 1.26.0); real floor for standalone consumers | Low | Kubernetes is on 1.26.0; standalone consumers need Go ≥ 1.24 |
| go.mod sub-module split (11 new entries) | Vendoring | Kubernetes vendor directory gains 11 new module paths | Very Low | go mod vendor handles this automatically |
This analysis used 5 parallel research agents and independent code inspection. Key findings were cross-checked:
| Finding | Agent finding | Independent verification | Consistent? |
|---|---|---|---|
| IsFloat64AJSONInteger diverges | Agent 2: algorithm refactor, new uses math.Round, confirmed behavioral change; Agent 1 (stalled): commit ebc624612f, PR #132, "improve equality comparison for floats, ~14% faster" |
Direct code comparison + empirical test program (/tmp/floattest/) confirming specific divergence cases |
✅ Fully consistent |
| yaml v3 bump is no-op | Agent 4: only go.mod change, confirmed | Checked GitHub diff programmatically | ✅ Fully consistent |
| WriteJSON/ReadJSON safe for kube-openapi | Agent 2: no easyjson types in kube-openapi; Agent 3 (stalled): "easyjson adapter is opt-in" | Read source of jsonutils/json.go, adapter registry, stdlib adapter; confirmed via grepping for easyjson in kube-openapi |
✅ Fully consistent |
| All symbols re-exported | Agent 2: all 30 import files traced | Inspected all *_iface.go files in v0.25.4 root |
✅ Fully consistent |
| Tests pass | Agent 2: "go build ./... clean, go test ./... all green" | go test ./... executed on PR branch: all packages pass |
✅ Fully consistent |
| k8s already has swag v0.25.4 | Not covered by agents | curl kubernetes/kubernetes go.mod — confirmed |
Additional finding |
The PR is safe to approve. The behavioral change in IsFloat64AJSONInteger is intentional, has been in production via other paths (Kubernetes already has swag v0.25.4 indirectly), and the direction is toward more correct floating-point handling. All existing tests pass.
Suggested response to the reviewer (defensible, precise):
I checked the CRD-relevant swag usage rather than only relying on root-shim compatibility.
The only behavior change I found in a CRD validation path is
swag.IsFloat64AJSONInteger, used bymultipleOfand float-as-integer type checks. v0.23.0 used truncation-based (float64(uint64(f))) comparison; v0.25.4 uses nearest-integermath.Roundwith relative epsilon. That changes sub-epsilon boundary behavior symmetrically around integers, including negative values. Examples:3.9999999999and-3.9999999999now classify as integer, while1.000000001no longer does.I checked the other kube-openapi-used swag helpers:
Convert*,Format*,DefaultJSONNameProvider,ContainsStringsCI,ToDynamicJSON, andConcatJSON. I did not find another CRD validation behavior change.JSONMapSlicemarshaling changed for nil inputs ({}→null), but kube-openapi does not directly use that type in CRD validation.Importantly, Kubernetes master already vendors
swag v0.25.4(MVS selected it via another dependency), so this specificIsFloat64AJSONIntegerbehavior is already active in Kubernetes master CRD validation today. Tests pass, includinggo test ./...,test/integration, and-racefor impacted packages.Low risk. Suggested non-blocking follow-up: add targeted
multipleOffloating-point boundary tests to lock the new behavior.
No. Here is the per-callsite analysis:
MultipleOf (values.go:196): mult = data/factor is passed to IsFloat64AJSONInteger. The behavioral change has two directions:
- More permissive — quotients like
3.9999999999,-3.9999999999,0.999999999are now accepted (old: rejected). Previously-rejected CRD instance values become valid. Backward-compatible; no existing valid data breaks. - More strict —
1.000000001is now rejected (old: accepted). The old algorithm tolerateddiff / (2 * |f|) < 1e-9, which atf=1.0meansdiff < 2e-9. The new requiresdiff < 1e-9 * |rounded| = 1e-9(strict<). So1.0 + 1e-9was inside the old band, outside the new.
The stricter case requires a user to submit a value where data/factor = 1.0 + 1e-9 exactly — for example, 0.001000000001 with multipleOf: 0.001. This is theoretically reachable, but the ship has already sailed: Kubernetes master has been running swag v0.25.4 at the CRD validation callsite before this PR was proposed. No reports of broken CRD validation have surfaced.
isFloatInt (type.go:157): This checks raw float values (not quotients) to allow a number-typed JSON value like 3.0 to satisfy an integer schema. The change means 3.9999999999 now passes the integer type check (more permissive). The 1.0 + 1e-9 stricter case would require someone to submit that specific float as a literal integer — not a real-world scenario.
Conclusion: No workaround needed. The only action worth taking is locking the new behavior with tests (see below), so a future algorithm change doesn't silently shift semantics again.
- Add multipleOf floating-point boundary tests. Add cases to
values_test.gocovering both the permissive and symmetric directions:
// Lock the math.Round-based nearest-integer behavior
{factor: 0.001, data: 0.9999999999, valid: true}, // quotient 999.9999999 rounds to 1000 within epsilon
{factor: 1, data: 3.9999999999, valid: true}, // quotient rounds to 4 within epsilon
{factor: 1, data: -3.9999999999, valid: true}, // symmetric for negative values-
Add a schemaconv error path test. Add a fixture with an invalid
x-kubernetes-list-typevalue topkg/schemaconvtests to exercise thec.reportErrorpath that was changed. -
Monitor the adapter registry. The new
jsonutils.Registryis a mutable global. If any future dependency callsadapters.Registry.Register(...)at init time, it would affect all callers globally. Consider adding a comment or test asserting only the stdlib adapter is registered in kube-openapi's context. -
Track swag v0.25.x for sub-module behavioral drift. The sub-module architecture means future versions of individual sub-modules (e.g.,
swag/conv@v0.26) could be bumped independently. Set up a process to audit sub-module changelogs separately when they diverge.
When Kubernetes next vendors kube-openapi (after PR #590 merges), there is no incremental behavioral change to CRD validation. Kubernetes already vendors swag v0.25.4 at the CRD validation callsite — the IsFloat64AJSONInteger algorithm change is already live. The kube-openapi bump PR is a metadata update: kube-openapi's own go.mod will now correctly declare the swag version it was already resolving to. Standard go test ./... and CRD integration tests are sufficient; no special validation is required.
| File | Change | Risk |
|---|---|---|
.github/workflows/ci.yml |
Pin actions to SHA; drop Go 1.23, add 1.26 | None |
go.mod |
swag v0.23.0→v0.25.4; yaml/v3 v3.0.3→v3.0.4; add 11 sub-module entries; go directive 1.23→1.24 | Low (see §4) |
go.sum |
Corresponding hash entries | None |
pkg/schemaconv/openapi.go |
3× c.reportError(err.Error()) → c.reportError("%v", err) |
None (vet fix) |
pkg/schemaconv/proto_models.go |
5× same format string fix | None (vet fix) |
pkg/validation/validate/result.go |
2× fmt.Errorf(str) → fmt.Errorf("%s", str) |
None (vet fix) |
test/integration/go.mod |
Mirror of root go.mod changes | None |
test/integration/go.sum |
Mirror of root go.sum changes | None |
Executed test program at /tmp/floattest/main.go comparing old and new algorithms:
Note: the argument to IsFloat64AJSONInteger in the MultipleOf path is the computed quotient 1/factor * value, not the user's raw value. The table below shows the quotient actually passed to the function.
Quotient passed to fn How it arises OLD NEW Diff?
------------------------------------------------------------------------------------------
3.9999999999 (4.0 - 1e-10) multipleOf:1, user value 3.9999999999 false true CHANGE
3.999999999 (4.0 - 1e-9) multipleOf:1, user value 3.999999999 false true CHANGE
1.000000001 (1.0 + 1e-9) multipleOf:1, user value 1.0 + 1e-9 true false CHANGE
999.9999999 (1000 - 1e-7) multipleOf:0.001, user value ~1.0 false true CHANGE
4.9999999998 (5.0 - 2e-10) multipleOf:0.5, user value 2.4999999999 false true CHANGE
6715.0 (exact integer) any factor, exact multiple true true
-3.0 (exact negative int) any factor, exact multiple true true
3.0 (= 0.3 / 0.1) multipleOf:0.1, user value 0.3 true true
314 (= 3.14 / 0.01) multipleOf:0.01, user value 3.14 true true
All divergences are at sub-epsilon-of-integer floating-point boundaries. The root cause is the boundary asymmetry in the old algorithm: float64(uint64(f)) truncates toward zero, so a quotient of 3.9999999999 gets compared against truncated value 3.0 (distance ≈ 1.0, far outside epsilon) instead of the nearest integer 4.0 (distance ≈ 1e-10, inside epsilon). The new math.Round-based algorithm is symmetric and correct.
Kubernetes master today (as of early 2026)
───────────────────────────────────────────
go.mod: go 1.26.0
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a (March 17, 2026)
github.com/go-openapi/swag v0.25.4 // indirect (MVS-selected)
vendor/modules.txt:
# github.com/go-openapi/swag v0.25.4
## explicit; go 1.24.0
vendor/github.com/go-openapi/swag/
└─ *.go ← v0.25.4 shim files (IsFloat64AJSONInteger delegates to conv sub-module)
vendor/k8s.io/kube-openapi/pkg/validation/validate/values.go
└─ import "github.com/go-openapi/swag"
└─ resolves to vendor/github.com/go-openapi/swag → v0.25.4
RESULT: CRD multipleOf validation in Kubernetes today uses
IsFloat64AJSONInteger from swag v0.25.4 (math.Round algorithm).
The behavioral change is already live.
After PR #590 merges and k8s updates kube-openapi:
← No behavioral change. kube-openapi go.mod now matches what Kubernetes
already has in vendor. The effective code at the CRD callsite is unchanged.
Key point: Go's flat vendoring means there is only one copy of go-openapi/swag in the Kubernetes vendor directory — the MVS-selected version (v0.25.4). Both the newly updated kube-openapi and all other dependencies use the same vendored copy. PR #590's merge has zero incremental impact on CRD validation behavior.