Files
cozystack/pkg/cmd/server/multikind_test.go
Aleksei Sviridkin 3dde6503ec refactor(api): keep each app's kind without the apimachinery fork
Every apps kind is registered on the one Application Go type, and the
stock Scheme.ConvertToVersion picks the first kind registered for a
type, so without help a Redis comes back from the API as whichever app
kind was registered first. So far the fix lived in a fork of
k8s.io/apimachinery that patches ConvertToVersion. Upstream has not
taken the change, and the fork has to be rebased onto every Kubernetes
minor before the libraries can move.

The apps group now encodes through a serializer that picks the kind the
object carries, along the lines upstream suggested instead of changing
Scheme. The replace directive goes, and with it the Renovate rule that
kept the fork's stale release tags from being proposed as updates. The
serializer relies on the storage stamping the kind on every object it
returns, which it does. A test drives create, get, list, update, watch,
apply and table through the real handler chain for two kinds and fails
without the serializer on upstream apimachinery.

One path converts through the Scheme rather than the serializer and
goes back to the first registered kind: the row objects of a Table
requested with includeObject=Object, which kubectl asks for only with
--sort-by. Admission webhooks and ValidatingAdmissionPolicy get the
request kind set explicitly.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-09-26 16:08:24 +03:00

154 lines
4.8 KiB
Go

/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package server_test
import (
"context"
"encoding/json"
"io"
"net/http"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/dynamic"
restclient "k8s.io/client-go/rest"
appsv1alpha1 "github.com/cozystack/cozystack/pkg/apis/apps/v1alpha1"
"github.com/cozystack/cozystack/pkg/config"
)
// Every apps kind is registered on the one Application Go type, and a stock
// conversion picks the first kind registered for it. Each request path must
// answer with the kind the client asked for. Every kind is exercised because
// the kinds share the process-wide scheme with other tests, so which one is
// registered first, and therefore invisible to this check, is not fixed.
func TestAppsAPIKeepsKindAcrossSharedGoType(t *testing.T) {
rc := &config.ResourceConfig{Resources: []config.Resource{
{
Application: config.ApplicationConfig{Kind: "Postgres", Singular: "postgres", Plural: "postgreses"},
Release: config.ReleaseConfig{Prefix: "postgres-"},
},
redisConfig,
}}
ts, _ := startAppsServer(t, rc)
dyn := dynamic.NewForConfigOrDie(&restclient.Config{Host: ts.URL})
for _, res := range rc.Resources {
kind, plural := res.Application.Kind, res.Application.Plural
t.Run(kind, func(t *testing.T) {
api := dyn.Resource(schema.GroupVersionResource{
Group: appsv1alpha1.GroupName, Version: "v1alpha1", Resource: plural,
}).Namespace("tenant-a")
ctx := context.Background()
wantKind := func(op, got, want string) {
t.Helper()
if got != want {
t.Errorf("%s: kind = %q, want %q", op, got, want)
}
}
obj := func(replicas int64) *unstructured.Unstructured {
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": appsv1alpha1.SchemeGroupVersion.String(),
"kind": kind,
"metadata": map[string]any{"name": "app", "namespace": "tenant-a"},
"spec": map[string]any{"replicas": replicas},
}}
}
created, err := api.Create(ctx, obj(1), metav1.CreateOptions{})
if err != nil {
t.Fatalf("create: %v", err)
}
wantKind("create", created.GetKind(), kind)
got, err := api.Get(ctx, "app", metav1.GetOptions{})
if err != nil {
t.Fatalf("get: %v", err)
}
wantKind("get", got.GetKind(), kind)
list, err := api.List(ctx, metav1.ListOptions{})
if err != nil {
t.Fatalf("list: %v", err)
}
wantKind("list", list.GetKind(), kind+"List")
w, err := api.Watch(ctx, metav1.ListOptions{})
if err != nil {
t.Fatalf("watch: %v", err)
}
defer w.Stop()
if err := unstructured.SetNestedField(got.Object, int64(3), "spec", "replicas"); err != nil {
t.Fatal(err)
}
updated, err := api.Update(ctx, got, metav1.UpdateOptions{})
if err != nil {
t.Fatalf("update: %v", err)
}
wantKind("update", updated.GetKind(), kind)
deadline := time.After(10 * time.Second)
for sawModified := false; !sawModified; {
select {
case ev := <-w.ResultChan():
u, ok := ev.Object.(*unstructured.Unstructured)
if !ok {
t.Fatalf("watch: unexpected object %T", ev.Object)
}
wantKind("watch "+string(ev.Type), u.GetKind(), kind)
sawModified = ev.Type == watch.Modified
case <-deadline:
t.Fatal("watch: no MODIFIED event")
}
}
applied, err := api.Apply(ctx, "app", obj(5), metav1.ApplyOptions{FieldManager: "test", Force: true})
if err != nil {
t.Fatalf("apply: %v", err)
}
wantKind("apply", applied.GetKind(), kind)
req, err := http.NewRequest(http.MethodGet, ts.URL+"/apis/apps.cozystack.io/v1alpha1/namespaces/tenant-a/"+plural, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Accept", "application/json;as=Table;v=v1;g=meta.k8s.io")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("table: %v", err)
}
body, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
t.Fatalf("table: read body: %v", err)
}
var table map[string]any
if err := json.Unmarshal(body, &table); err != nil {
t.Fatalf("table: decode %q: %v", body, err)
}
if resp.StatusCode != http.StatusOK || table["kind"] != "Table" {
t.Errorf("table: status %d kind %v", resp.StatusCode, table["kind"])
}
})
}
}