Skip to content

Commit 2272d9b

Browse files
authored
🤖 fix: map aggregated OpenAPI schemas to GVK for SSA (#83)
## Summary Fix aggregated API OpenAPI model metadata so server-side apply can map `aggregation.coder.com/v1alpha1` resources to structured-merge-diff types. ## Background `kubectl apply --server-side` was failing during managed fields processing with: - `failed to convert new object to smd typed` - `no corresponding type for aggregation.coder.com/v1alpha1, Kind=CoderTemplate` The aggregated API server defines OpenAPI schemas manually, but those definitions were missing the `x-kubernetes-group-version-kind` extension that managedfields uses to index object types. ## Implementation - Added `x-kubernetes-group-version-kind` extensions to manual OpenAPI definitions for: - `CoderWorkspace` - `CoderWorkspaceList` - `CoderTemplate` - `CoderTemplateList` - Added top-level object fields to the manual schemas (`apiVersion`, `kind`, `metadata`) and list metadata fields. - Added regression tests that assert: - template schema includes the GVK extension and object fields - managedfields `TypeConverter` can convert a `CoderTemplate` object to SMD typed form ## Validation - `make verify-vendor` - `make test` - `make build` - `make lint` - `GOFLAGS=-mod=vendor go test ./internal/app/apiserverapp -count=1` ## Risks Low-to-moderate: - This changes manual OpenAPI model metadata only. - Main risk is schema shape drift for clients relying on previous minimal definitions. - Covered by new unit tests focused on GVK extension and managedfields conversion. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh`_ --- _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$0.29`_ <!-- mux-attribution: model=openai:gpt-5.3-codex thinking=xhigh costs=0.29 -->
1 parent 515a7e4 commit 2272d9b

File tree

2 files changed

+147
-0
lines changed

2 files changed

+147
-0
lines changed

‎internal/app/apiserverapp/apiserverapp.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,10 +345,26 @@ func getOpenAPIDefinitions(_ openapicommon.ReferenceCallback) map[string]openapi
345345
templateDefinitionName := openapiutil.GetCanonicalTypeName(&aggregationv1alpha1.CoderTemplate{})
346346
templateListDefinitionName := openapiutil.GetCanonicalTypeName(&aggregationv1alpha1.CoderTemplateList{})
347347

348+
groupVersionKindExtension := func(kind string) spec.VendorExtensible {
349+
return spec.VendorExtensible{
350+
Extensions: spec.Extensions{
351+
"x-kubernetes-group-version-kind": []interface{}{
352+
map[string]interface{}{
353+
"group": aggregationv1alpha1.SchemeGroupVersion.Group,
354+
"version": aggregationv1alpha1.SchemeGroupVersion.Version,
355+
"kind": kind,
356+
},
357+
},
358+
},
359+
}
360+
}
361+
348362
boolSchema := spec.Schema{SchemaProps: spec.SchemaProps{Type: []string{"boolean"}}}
349363
dateTimeSchema := spec.Schema{SchemaProps: spec.SchemaProps{Type: []string{"string"}, Format: "date-time"}}
350364
int64Schema := spec.Schema{SchemaProps: spec.SchemaProps{Type: []string{"integer"}, Format: "int64"}}
351365
stringSchema := spec.Schema{SchemaProps: spec.SchemaProps{Type: []string{"string"}}}
366+
objectMetaSchema := spec.Schema{SchemaProps: spec.SchemaProps{Type: []string{"object"}}}
367+
listMetaSchema := spec.Schema{SchemaProps: spec.SchemaProps{Type: []string{"object"}}}
352368
filesSchema := spec.Schema{
353369
VendorExtensible: spec.VendorExtensible{
354370
Extensions: spec.Extensions{
@@ -365,9 +381,13 @@ func getOpenAPIDefinitions(_ openapicommon.ReferenceCallback) map[string]openapi
365381
}
366382

367383
workspaceSchema := spec.Schema{
384+
VendorExtensible: groupVersionKindExtension("CoderWorkspace"),
368385
SchemaProps: spec.SchemaProps{
369386
Type: []string{"object"},
370387
Properties: map[string]spec.Schema{
388+
"apiVersion": stringSchema,
389+
"kind": stringSchema,
390+
"metadata": objectMetaSchema,
371391
"spec": {
372392
SchemaProps: spec.SchemaProps{
373393
Type: []string{"object"},
@@ -401,9 +421,13 @@ func getOpenAPIDefinitions(_ openapicommon.ReferenceCallback) map[string]openapi
401421
}
402422

403423
templateSchema := spec.Schema{
424+
VendorExtensible: groupVersionKindExtension("CoderTemplate"),
404425
SchemaProps: spec.SchemaProps{
405426
Type: []string{"object"},
406427
Properties: map[string]spec.Schema{
428+
"apiVersion": stringSchema,
429+
"kind": stringSchema,
430+
"metadata": objectMetaSchema,
407431
"spec": {
408432
SchemaProps: spec.SchemaProps{
409433
Type: []string{"object"},
@@ -436,9 +460,13 @@ func getOpenAPIDefinitions(_ openapicommon.ReferenceCallback) map[string]openapi
436460
}
437461

438462
workspaceListSchema := spec.Schema{
463+
VendorExtensible: groupVersionKindExtension("CoderWorkspaceList"),
439464
SchemaProps: spec.SchemaProps{
440465
Type: []string{"object"},
441466
Properties: map[string]spec.Schema{
467+
"apiVersion": stringSchema,
468+
"kind": stringSchema,
469+
"metadata": listMetaSchema,
442470
"items": {
443471
SchemaProps: spec.SchemaProps{
444472
Type: []string{"array"},
@@ -450,9 +478,13 @@ func getOpenAPIDefinitions(_ openapicommon.ReferenceCallback) map[string]openapi
450478
}
451479

452480
templateListSchema := spec.Schema{
481+
VendorExtensible: groupVersionKindExtension("CoderTemplateList"),
453482
SchemaProps: spec.SchemaProps{
454483
Type: []string{"object"},
455484
Properties: map[string]spec.Schema{
485+
"apiVersion": stringSchema,
486+
"kind": stringSchema,
487+
"metadata": listMetaSchema,
456488
"items": {
457489
SchemaProps: spec.SchemaProps{
458490
Type: []string{"array"},

‎internal/app/apiserverapp/apiserverapp_test.go‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ import (
1111
"time"
1212

1313
apierrors "k8s.io/apimachinery/pkg/api/errors"
14+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1415
"k8s.io/apimachinery/pkg/runtime/schema"
1516
"k8s.io/apimachinery/pkg/runtime/serializer"
17+
"k8s.io/apimachinery/pkg/util/managedfields"
1618
genericoptions "k8s.io/apiserver/pkg/server/options"
1719
openapiutil "k8s.io/kube-openapi/pkg/util"
20+
"k8s.io/kube-openapi/pkg/validation/spec"
1821

1922
aggregationv1alpha1 "github.com/coder/coder-k8s/api/aggregation/v1alpha1"
2023
coderhelper "github.com/coder/coder-k8s/internal/aggregated/coder"
@@ -76,6 +79,109 @@ func TestOpenAPIDefinitionsIncludeTemplateFiles(t *testing.T) {
7679
}
7780
}
7881

82+
func TestOpenAPIDefinitionsIncludeTemplateGVKExtensionAndObjectMetadata(t *testing.T) {
83+
t.Helper()
84+
85+
defs := getOpenAPIDefinitions(nil)
86+
templateDefinitionName := openapiutil.GetCanonicalTypeName(&aggregationv1alpha1.CoderTemplate{})
87+
88+
def, ok := defs[templateDefinitionName]
89+
if !ok {
90+
t.Fatalf("expected OpenAPI definition for %s", templateDefinitionName)
91+
}
92+
93+
for _, propertyName := range []string{"apiVersion", "kind", "metadata", "spec", "status"} {
94+
if _, ok := def.Schema.Properties[propertyName]; !ok {
95+
t.Fatalf("expected template schema to include %q", propertyName)
96+
}
97+
}
98+
99+
gvk := readGVKExtension(t, def.Schema)
100+
if got, want := gvk["group"], aggregationv1alpha1.SchemeGroupVersion.Group; got != want {
101+
t.Fatalf("expected template GVK group %q, got %v", want, got)
102+
}
103+
if got, want := gvk["version"], aggregationv1alpha1.SchemeGroupVersion.Version; got != want {
104+
t.Fatalf("expected template GVK version %q, got %v", want, got)
105+
}
106+
if got, want := gvk["kind"], "CoderTemplate"; got != want {
107+
t.Fatalf("expected template GVK kind %q, got %v", want, got)
108+
}
109+
}
110+
111+
func TestOpenAPIDefinitionsSupportManagedFieldsTypeConversionForTemplate(t *testing.T) {
112+
t.Helper()
113+
114+
defs := getOpenAPIDefinitions(nil)
115+
openAPISpec := make(map[string]*spec.Schema, len(defs))
116+
for definitionName, definition := range defs {
117+
definitionSchema := definition.Schema
118+
openAPISpec[definitionName] = &definitionSchema
119+
}
120+
121+
typeConverter, err := managedfields.NewTypeConverter(openAPISpec, false)
122+
if err != nil {
123+
t.Fatalf("build managed fields type converter from OpenAPI definitions: %v", err)
124+
}
125+
if typeConverter == nil {
126+
t.Fatal("expected managed fields type converter to be non-nil")
127+
}
128+
129+
template := &aggregationv1alpha1.CoderTemplate{
130+
TypeMeta: metav1.TypeMeta{
131+
APIVersion: aggregationv1alpha1.SchemeGroupVersion.String(),
132+
Kind: "CoderTemplate",
133+
},
134+
ObjectMeta: metav1.ObjectMeta{
135+
Name: "default.my-template",
136+
Namespace: "test-ns",
137+
},
138+
Spec: aggregationv1alpha1.CoderTemplateSpec{
139+
Organization: "default",
140+
VersionID: "version-id",
141+
},
142+
}
143+
144+
if _, err := typeConverter.ObjectToTyped(template); err != nil {
145+
t.Fatalf("convert template object to structured-merge typed value: %v", err)
146+
}
147+
}
148+
149+
func readGVKExtension(t *testing.T, schema spec.Schema) map[string]interface{} {
150+
t.Helper()
151+
152+
extension, ok := schema.Extensions["x-kubernetes-group-version-kind"]
153+
if !ok {
154+
t.Fatal("expected x-kubernetes-group-version-kind OpenAPI extension")
155+
}
156+
157+
gvkList, ok := extension.([]interface{})
158+
if !ok {
159+
t.Fatalf("expected GVK extension to be []interface{}, got %T", extension)
160+
}
161+
if len(gvkList) != 1 {
162+
t.Fatalf("expected exactly one GVK entry, got %d", len(gvkList))
163+
}
164+
165+
switch gvk := gvkList[0].(type) {
166+
case map[string]interface{}:
167+
return gvk
168+
case map[interface{}]interface{}:
169+
normalized := make(map[string]interface{}, len(gvk))
170+
for key, value := range gvk {
171+
keyString, ok := key.(string)
172+
if !ok {
173+
t.Fatalf("expected GVK extension map key to be string, got %T", key)
174+
}
175+
normalized[keyString] = value
176+
}
177+
return normalized
178+
default:
179+
t.Fatalf("expected GVK entry to be map, got %T", gvkList[0])
180+
}
181+
182+
return nil
183+
}
184+
79185
func TestInstallAPIGroupRegistersDiscovery(t *testing.T) {
80186
t.Helper()
81187

@@ -196,6 +302,15 @@ func TestNewRecommendedConfigSetsExtendedRequestTimeout(t *testing.T) {
196302
if got, want := recommendedConfig.RequestTimeout, defaultRequestTimeout; got != want {
197303
t.Fatalf("expected request timeout %s, got %s", want, got)
198304
}
305+
if !recommendedConfig.SkipOpenAPIInstallation {
306+
t.Fatal("expected OpenAPI handler installation to remain disabled until generic definitions are wired")
307+
}
308+
if recommendedConfig.OpenAPIConfig == nil {
309+
t.Fatal("expected non-nil OpenAPI v2 config")
310+
}
311+
if recommendedConfig.OpenAPIV3Config == nil {
312+
t.Fatal("expected non-nil OpenAPI v3 config")
313+
}
199314
}
200315

201316
func TestBuildClientProviderDefersMissingCoderConfigAsServiceUnavailable(t *testing.T) {

0 commit comments

Comments
 (0)