mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-12 23:54:01 +09:00
Backing resource names are not part of the aggregated API contract. Clients need the requested resource identity and the original status code and reason to handle rejected writes consistently. Signed-off-by: Yan Bondarenko <202671653+yankawai@users.noreply.github.com> Assisted-by: LLM
316 lines
12 KiB
Go
316 lines
12 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 application
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
|
|
helmv2 "github.com/fluxcd/helm-controller/api/v2"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/apiserver/pkg/endpoints/handlers/responsewriters"
|
|
"k8s.io/apiserver/pkg/endpoints/request"
|
|
"k8s.io/apiserver/pkg/registry/rest"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
|
|
|
|
appsv1alpha1 "github.com/cozystack/cozystack/pkg/apis/apps/v1alpha1"
|
|
"github.com/cozystack/cozystack/pkg/config"
|
|
)
|
|
|
|
const optionsTestNamespace = "tenant-options"
|
|
|
|
func newOptionsTestREST(t *testing.T, funcs interceptor.Funcs, objects ...client.Object) *REST {
|
|
t.Helper()
|
|
scheme := runtime.NewScheme()
|
|
if err := cozyv1alpha1.AddToScheme(scheme); err != nil {
|
|
t.Fatalf("register Cozystack API scheme: %v", err)
|
|
}
|
|
if err := helmv2.AddToScheme(scheme); err != nil {
|
|
t.Fatalf("register HelmRelease scheme: %v", err)
|
|
}
|
|
c := fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(objects...).
|
|
WithInterceptorFuncs(funcs).
|
|
Build()
|
|
return NewREST(c, nil, &config.Resource{
|
|
Application: config.ApplicationConfig{
|
|
Kind: "PostgreSQL",
|
|
Plural: "postgresqls",
|
|
Singular: "postgresql",
|
|
},
|
|
Release: config.ReleaseConfig{Prefix: "postgresql-"},
|
|
})
|
|
}
|
|
|
|
func optionsTestApplication() *appsv1alpha1.Application {
|
|
return &appsv1alpha1.Application{
|
|
TypeMeta: metav1.TypeMeta{APIVersion: "apps.cozystack.io/v1alpha1", Kind: "PostgreSQL"},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "example",
|
|
Namespace: optionsTestNamespace,
|
|
},
|
|
}
|
|
}
|
|
|
|
func optionsTestHelmRelease() *helmv2.HelmRelease {
|
|
return &helmv2.HelmRelease{ObjectMeta: metav1.ObjectMeta{
|
|
Name: "postgresql-example",
|
|
Namespace: optionsTestNamespace,
|
|
Labels: map[string]string{
|
|
ApplicationKindLabel: "PostgreSQL",
|
|
ApplicationGroupLabel: appsv1alpha1.GroupName,
|
|
ApplicationNameLabel: "example",
|
|
},
|
|
}}
|
|
}
|
|
|
|
func captureCreateOptions(opts ...client.CreateOption) *metav1.CreateOptions {
|
|
converted := (&client.CreateOptions{}).ApplyOptions(opts)
|
|
return converted.AsCreateOptions().DeepCopy()
|
|
}
|
|
|
|
func captureUpdateOptions(opts ...client.UpdateOption) *metav1.UpdateOptions {
|
|
converted := (&client.UpdateOptions{}).ApplyOptions(opts)
|
|
return converted.AsUpdateOptions().DeepCopy()
|
|
}
|
|
|
|
func captureDeleteOptions(opts ...client.DeleteOption) *metav1.DeleteOptions {
|
|
converted := &client.DeleteOptions{}
|
|
for _, option := range opts {
|
|
option.ApplyToDelete(converted)
|
|
}
|
|
return converted.AsDeleteOptions().DeepCopy()
|
|
}
|
|
|
|
func TestCreatePropagatesWriteOptions(t *testing.T) {
|
|
want := &metav1.CreateOptions{
|
|
DryRun: []string{metav1.DryRunAll},
|
|
FieldManager: "create-manager",
|
|
FieldValidation: "Strict",
|
|
}
|
|
expected := want.DeepCopy()
|
|
var got *metav1.CreateOptions
|
|
r := newOptionsTestREST(t, interceptor.Funcs{
|
|
Create: func(_ context.Context, _ client.WithWatch, _ client.Object, opts ...client.CreateOption) error {
|
|
got = captureCreateOptions(opts...)
|
|
return nil
|
|
},
|
|
})
|
|
ctx := request.WithNamespace(context.Background(), optionsTestNamespace)
|
|
if _, err := r.Create(ctx, optionsTestApplication(), nil, want); err != nil {
|
|
t.Fatalf("Create returned error: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(got, expected) {
|
|
t.Fatalf("Create options lost in controller-runtime conversion:\n got: %#v\nwant: %#v", got, expected)
|
|
}
|
|
}
|
|
|
|
func TestUpdatePropagatesWriteOptions(t *testing.T) {
|
|
want := &metav1.UpdateOptions{
|
|
DryRun: []string{metav1.DryRunAll},
|
|
FieldManager: "update-manager",
|
|
FieldValidation: "Warn",
|
|
}
|
|
expected := want.DeepCopy()
|
|
var got *metav1.UpdateOptions
|
|
r := newOptionsTestREST(t, interceptor.Funcs{
|
|
Update: func(_ context.Context, _ client.WithWatch, _ client.Object, opts ...client.UpdateOption) error {
|
|
got = captureUpdateOptions(opts...)
|
|
return nil
|
|
},
|
|
}, optionsTestHelmRelease())
|
|
ctx := request.WithNamespace(context.Background(), optionsTestNamespace)
|
|
if _, _, err := r.Update(ctx, "example", rest.DefaultUpdatedObjectInfo(optionsTestApplication()), nil, nil, false, want); err != nil {
|
|
t.Fatalf("Update returned error: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(got, expected) {
|
|
t.Fatalf("Update options lost in controller-runtime conversion:\n got: %#v\nwant: %#v", got, expected)
|
|
}
|
|
}
|
|
|
|
func TestUpdateForceCreatePropagatesWriteOptions(t *testing.T) {
|
|
update := &metav1.UpdateOptions{
|
|
DryRun: []string{metav1.DryRunAll},
|
|
FieldManager: "apply-manager",
|
|
FieldValidation: "Strict",
|
|
}
|
|
want := &metav1.CreateOptions{
|
|
DryRun: update.DryRun,
|
|
FieldManager: update.FieldManager,
|
|
FieldValidation: update.FieldValidation,
|
|
}
|
|
var got *metav1.CreateOptions
|
|
r := newOptionsTestREST(t, interceptor.Funcs{
|
|
Create: func(_ context.Context, _ client.WithWatch, _ client.Object, opts ...client.CreateOption) error {
|
|
got = captureCreateOptions(opts...)
|
|
return nil
|
|
},
|
|
})
|
|
ctx := request.WithNamespace(context.Background(), optionsTestNamespace)
|
|
if _, _, err := r.Update(ctx, "example", rest.DefaultUpdatedObjectInfo(optionsTestApplication()), nil, nil, true, update); err != nil {
|
|
t.Fatalf("force-create Update returned error: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("force-create options lost in controller-runtime conversion:\n got: %#v\nwant: %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestDeletePropagatesWriteOptions(t *testing.T) {
|
|
grace := int64(3)
|
|
propagation := metav1.DeletePropagationForeground
|
|
uid := types.UID("expected-uid")
|
|
rv := "17"
|
|
want := &metav1.DeleteOptions{
|
|
GracePeriodSeconds: &grace,
|
|
Preconditions: &metav1.Preconditions{UID: &uid, ResourceVersion: &rv},
|
|
PropagationPolicy: &propagation,
|
|
DryRun: []string{metav1.DryRunAll},
|
|
}
|
|
expected := want.DeepCopy()
|
|
var got *metav1.DeleteOptions
|
|
r := newOptionsTestREST(t, interceptor.Funcs{
|
|
Delete: func(_ context.Context, _ client.WithWatch, _ client.Object, opts ...client.DeleteOption) error {
|
|
got = captureDeleteOptions(opts...)
|
|
return nil
|
|
},
|
|
}, optionsTestHelmRelease())
|
|
ctx := request.WithNamespace(context.Background(), optionsTestNamespace)
|
|
if _, _, err := r.Delete(ctx, "example", nil, want); err != nil {
|
|
t.Fatalf("Delete returned error: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(got, expected) {
|
|
t.Fatalf("Delete options lost in controller-runtime conversion:\n got: %#v\nwant: %#v", got, expected)
|
|
}
|
|
}
|
|
|
|
// assertWireStatus checks the answer the aggregated endpoint would put on the
|
|
// wire: ErrorToAPIStatus type-switches on the APIStatus interface and never
|
|
// unwraps, so a merely fmt.Errorf-wrapped backend rejection collapses to a
|
|
// generic 500 with an empty reason even while errors.Is/As still succeed.
|
|
func assertWireStatus(t *testing.T, err error, wantCode int32, wantReason metav1.StatusReason, wantContext string) {
|
|
t.Helper()
|
|
status := responsewriters.ErrorToAPIStatus(err)
|
|
if status.Code != wantCode || status.Reason != wantReason {
|
|
t.Fatalf("wire status = %d %q, want %d %q (err: %v)", status.Code, status.Reason, wantCode, wantReason, err)
|
|
}
|
|
if !strings.Contains(status.Message, wantContext) {
|
|
t.Fatalf("wire message %q lost the %q context", status.Message, wantContext)
|
|
}
|
|
if status.Details == nil || status.Details.Group != appsv1alpha1.GroupName || status.Details.Kind != "postgresqls" || status.Details.Name != "example" {
|
|
t.Fatalf("wire details = %#v, want apps.cozystack.io/postgresqls example", status.Details)
|
|
}
|
|
if !strings.HasPrefix(status.Message, `postgresqls.apps.cozystack.io "example": `) {
|
|
t.Fatalf("wire message %q does not identify the requested Application", status.Message)
|
|
}
|
|
}
|
|
|
|
func TestCreatePreservesBackendStatus(t *testing.T) {
|
|
alreadyExists := apierrors.NewAlreadyExists(
|
|
schema.GroupResource{Group: helmv2.GroupVersion.Group, Resource: "helmreleases"},
|
|
"postgresql-example",
|
|
)
|
|
r := newOptionsTestREST(t, interceptor.Funcs{
|
|
Create: func(_ context.Context, _ client.WithWatch, _ client.Object, _ ...client.CreateOption) error {
|
|
return alreadyExists
|
|
},
|
|
})
|
|
ctx := request.WithNamespace(context.Background(), optionsTestNamespace)
|
|
_, err := r.Create(ctx, optionsTestApplication(), nil, &metav1.CreateOptions{})
|
|
if !apierrors.IsAlreadyExists(err) {
|
|
t.Fatalf("Create returned %v, want a preserved AlreadyExists", err)
|
|
}
|
|
assertWireStatus(t, err, http.StatusConflict, metav1.StatusReasonAlreadyExists, "failed to create HelmRelease")
|
|
}
|
|
|
|
func TestUpdatePreservesBackendStatus(t *testing.T) {
|
|
forbidden := apierrors.NewForbidden(
|
|
schema.GroupResource{Group: helmv2.GroupVersion.Group, Resource: "helmreleases"},
|
|
"postgresql-example",
|
|
fmt.Errorf("rejected by admission policy"),
|
|
)
|
|
r := newOptionsTestREST(t, interceptor.Funcs{
|
|
Update: func(_ context.Context, _ client.WithWatch, _ client.Object, _ ...client.UpdateOption) error {
|
|
return forbidden
|
|
},
|
|
}, optionsTestHelmRelease())
|
|
ctx := request.WithNamespace(context.Background(), optionsTestNamespace)
|
|
_, _, err := r.Update(ctx, "example", rest.DefaultUpdatedObjectInfo(optionsTestApplication()), nil, nil, false, &metav1.UpdateOptions{})
|
|
if !apierrors.IsForbidden(err) {
|
|
t.Fatalf("Update returned %v, want a preserved Forbidden", err)
|
|
}
|
|
assertWireStatus(t, err, http.StatusForbidden, metav1.StatusReasonForbidden, "failed to update HelmRelease")
|
|
}
|
|
|
|
func TestUpdatePreservesStatusWhenCurrentHelmReleaseDisappears(t *testing.T) {
|
|
getCalls := 0
|
|
r := newOptionsTestREST(t, interceptor.Funcs{
|
|
Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
|
|
if _, ok := obj.(*helmv2.HelmRelease); ok {
|
|
getCalls++
|
|
if getCalls == 2 {
|
|
return apierrors.NewNotFound(helmv2.GroupVersion.WithResource("helmreleases").GroupResource(), key.Name)
|
|
}
|
|
}
|
|
return c.Get(ctx, key, obj, opts...)
|
|
},
|
|
Update: func(_ context.Context, _ client.WithWatch, _ client.Object, _ ...client.UpdateOption) error {
|
|
t.Fatal("Update wrote a HelmRelease after its current state could not be read")
|
|
return nil
|
|
},
|
|
}, optionsTestHelmRelease())
|
|
ctx := request.WithNamespace(context.Background(), optionsTestNamespace)
|
|
_, _, err := r.Update(ctx, "example", rest.DefaultUpdatedObjectInfo(optionsTestApplication()), nil, nil, false, &metav1.UpdateOptions{})
|
|
if getCalls != 2 {
|
|
t.Fatalf("HelmRelease reads = %d, want the initial read and the current-state read", getCalls)
|
|
}
|
|
if !apierrors.IsNotFound(err) {
|
|
t.Fatalf("Update returned %v, want a preserved NotFound", err)
|
|
}
|
|
assertWireStatus(t, err, http.StatusNotFound, metav1.StatusReasonNotFound, "failed to fetch current HelmRelease")
|
|
}
|
|
|
|
func TestDeletePreservesConflictError(t *testing.T) {
|
|
conflict := apierrors.NewConflict(
|
|
schema.GroupResource{Group: helmv2.GroupVersion.Group, Resource: "helmreleases"},
|
|
"postgresql-example",
|
|
fmt.Errorf("precondition failed"),
|
|
)
|
|
r := newOptionsTestREST(t, interceptor.Funcs{
|
|
Delete: func(_ context.Context, _ client.WithWatch, _ client.Object, _ ...client.DeleteOption) error {
|
|
return conflict
|
|
},
|
|
}, optionsTestHelmRelease())
|
|
ctx := request.WithNamespace(context.Background(), optionsTestNamespace)
|
|
_, _, err := r.Delete(ctx, "example", nil, &metav1.DeleteOptions{})
|
|
if !apierrors.IsConflict(err) {
|
|
t.Fatalf("Delete returned %v, want a preserved Conflict", err)
|
|
}
|
|
assertWireStatus(t, err, http.StatusConflict, metav1.StatusReasonConflict, "failed to delete HelmRelease")
|
|
}
|