chore: add deprecation notices (#2089)

* fix: revert pv labeling to dircet api reads

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* fix: revert pv labeling to dircet api reads

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add deprecation warnings

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* chore: implement playground

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* chore: implement playground

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

---------

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
This commit is contained in:
Oliver Bähler
2026-08-19 15:08:47 +02:00
committed by GitHub
parent e714dbcc2a
commit 647b113702
85 changed files with 2452 additions and 517 deletions
+20
View File
@@ -70,6 +70,10 @@ func (h *validationHandler) handle(
config *capsulev1beta2.CapsuleConfiguration,
req admission.Request,
) *admission.Response {
if err := validateAdmissionClients(config.Spec.Admission); err != nil {
return ad.Deny(err.Error())
}
if err := h.validateRegex(
"spec.protectedNamespaceRegex",
config.Spec.ProtectedNamespaceRegexpString,
@@ -94,6 +98,22 @@ func (h *validationHandler) handle(
return nil
}
func validateAdmissionClients(config capsulev1beta2.DynamicAdmission) error {
if config.Validating != nil {
if err := ad.ValidateWebhookClientConfig(config.Validating.Client); err != nil {
return fmt.Errorf("spec.admission.validating.client is invalid: %w", err)
}
}
if config.Mutating != nil {
if err := ad.ValidateWebhookClientConfig(config.Mutating.Client); err != nil {
return fmt.Errorf("spec.admission.mutating.client is invalid: %w", err)
}
}
return nil
}
func (h *validationHandler) validateRegex(fieldPath string, value string) error {
if strings.TrimSpace(value) == "" {
return nil
+94
View File
@@ -0,0 +1,94 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package cfg
import (
"testing"
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
runtimeadmission "github.com/projectcapsule/capsule/pkg/runtime/admission"
)
func TestValidateAdmissionClients(t *testing.T) {
t.Parallel()
webhookURL := "https://capsule.example.com"
service := &admissionregistrationv1.ServiceReference{
Name: "capsule-webhook-service",
Namespace: "capsule-system",
}
tests := []struct {
name string
config capsulev1beta2.DynamicAdmission
wantErr bool
}{
{name: "not configured"},
{
name: "validating url",
config: capsulev1beta2.DynamicAdmission{
Validating: dynamicValidatingConfig(&admissionregistrationv1.WebhookClientConfig{URL: &webhookURL}),
},
},
{
name: "mutating service",
config: capsulev1beta2.DynamicAdmission{
Mutating: dynamicMutatingConfig(&admissionregistrationv1.WebhookClientConfig{Service: service}),
},
},
{
name: "validating missing client",
config: capsulev1beta2.DynamicAdmission{
Validating: dynamicValidatingConfig(nil),
},
wantErr: true,
},
{
name: "mutating has neither",
config: capsulev1beta2.DynamicAdmission{
Mutating: dynamicMutatingConfig(&admissionregistrationv1.WebhookClientConfig{}),
},
wantErr: true,
},
{
name: "mutating has both",
config: capsulev1beta2.DynamicAdmission{
Mutating: dynamicMutatingConfig(&admissionregistrationv1.WebhookClientConfig{
URL: &webhookURL,
Service: service,
}),
},
wantErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
err := validateAdmissionClients(test.config)
if (err != nil) != test.wantErr {
t.Fatalf("validateAdmissionClients() error = %v, wantErr %t", err, test.wantErr)
}
})
}
}
func dynamicValidatingConfig(
client *admissionregistrationv1.WebhookClientConfig,
) *capsulev1beta2.DynamicValidatingAdmissionConfig {
return &capsulev1beta2.DynamicValidatingAdmissionConfig{
DynamicAdmissionConfig: runtimeadmission.DynamicAdmissionConfig{Client: client},
}
}
func dynamicMutatingConfig(
client *admissionregistrationv1.WebhookClientConfig,
) *capsulev1beta2.DynamicMutatingAdmissionConfig {
return &capsulev1beta2.DynamicMutatingAdmissionConfig{
DynamicAdmissionConfig: runtimeadmission.DynamicAdmissionConfig{Client: client},
}
}
@@ -24,6 +24,7 @@ func RequiredMetadataHandler() handlers.TypedHandlerWithTenantUser[*corev1.Names
return &requiredMetadataHandler{}
}
//nolint:staticcheck
func (h *requiredMetadataHandler) OnCreate(
_ client.Client,
_ client.Reader,
@@ -61,6 +62,7 @@ func (h *requiredMetadataHandler) OnCreate(
}
}
//nolint:staticcheck
func (h *requiredMetadataHandler) OnUpdate(
_ client.Client,
_ client.Reader,
@@ -149,6 +149,7 @@ func (h *userMetadataHandler) OnDelete(
}
}
//nolint:staticcheck
func validateUserMetadata(
ctx context.Context,
req admission.Request,
+1
View File
@@ -72,6 +72,7 @@ func (h *validating) OnDelete(
}
}
//nolint:staticcheck
func (h *validating) handle(
ctx context.Context,
req admission.Request,
@@ -67,6 +67,7 @@ func (h *forbiddenAnnotationsRegexHandler) OnUpdate(
}
}
//nolint:staticcheck
func (h *forbiddenAnnotationsRegexHandler) validate(tnt *capsulev1beta2.Tenant, req admission.Request) *admission.Response {
if tnt == nil || tnt.Spec.NamespaceOptions == nil {
return nil
@@ -63,6 +63,7 @@ func (h *namespaceMetadataHandler) OnUpdate(
}
}
//nolint:staticcheck
func validateTenantNamespaceMetadata(tnt *capsulev1beta2.Tenant) *admission.Response {
if tnt == nil {
return nil
@@ -72,6 +72,7 @@ func (h *requiredMetadataHandler) OnUpdate(
}
}
//nolint:staticcheck
func (h *requiredMetadataHandler) validate(tnt *capsulev1beta2.Tenant, req admission.Request) *admission.Response {
no := tnt.Spec.NamespaceOptions
if no == nil || no.RequiredMetadata == nil {
@@ -118,6 +119,7 @@ func requiredMetadataChanged(oldT, newT *capsulev1beta2.Tenant) bool {
return false
}
//nolint:staticcheck
func getRequiredMetadata(t *capsulev1beta2.Tenant) *capsulev1beta2.RequiredMetadata {
// Adjust the return type to your actual struct type:
// e.g. *capsulev1beta2.NamespaceRequiredMetadata or similar.
+73 -6
View File
@@ -82,6 +82,8 @@ func (h *warningHandler) handle(tnt *capsulev1beta2.Tenant, req admission.Reques
}
}
response.Warnings = append(response.Warnings, deprecatedTenantFieldWarnings(tnt)...)
//nolint:staticcheck
if len(tnt.Spec.LimitRanges.Items) > 0 {
response.Warnings = append(response.Warnings,
@@ -96,12 +98,7 @@ func (h *warningHandler) handle(tnt *capsulev1beta2.Tenant, req admission.Reques
)
}
//nolint:staticcheck
if tnt.Spec.NamespaceOptions != nil && tnt.Spec.NamespaceOptions.AdditionalMetadata != nil {
response.Warnings = append(response.Warnings,
"The field `additionalMetadata` is deprecated and will be removed in a future release. Please migrate to `additionalMetadataList`. See: https://projectcapsule.dev/docs/tenants/metadata/#additionalmetadatalist.",
)
}
response.Warnings = append(response.Warnings, deprecatedNamespaceOptionWarnings(tnt)...)
//nolint:staticcheck
if tnt.Spec.StorageClasses != nil && tnt.Spec.StorageClasses.Regex != "" {
@@ -143,3 +140,73 @@ func (h *warningHandler) handle(tnt *capsulev1beta2.Tenant, req admission.Reques
return response
}
func deprecatedTenantFieldWarnings(tnt *capsulev1beta2.Tenant) (warnings []string) {
//nolint:staticcheck
if tnt.Spec.ResourceQuota.Scope != "" || len(tnt.Spec.ResourceQuota.Items) > 0 {
warnings = append(warnings,
"The field `resourceQuotas` is deprecated and will be removed in a future release. Please migrate to rules quotas. See: https://projectcapsule.dev/docs/tenants/rules/#quotas.",
)
}
//nolint:staticcheck
if tnt.Spec.ServiceOptions != nil {
warnings = append(warnings,
"The field `serviceOptions` is deprecated and will be removed in a future release. Please migrate to rules metadata. See: https://projectcapsule.dev/docs/rules/enforcement/metadata/.",
)
}
//nolint:staticcheck
if tnt.Spec.PodOptions != nil {
warnings = append(warnings,
"The field `podOptions` is deprecated and will be removed in a future release. Please migrate to rules metadata. See: https://projectcapsule.dev/docs/rules/enforcement/metadata/.",
)
}
return warnings
}
func deprecatedNamespaceOptionWarnings(tnt *capsulev1beta2.Tenant) (warnings []string) {
if tnt.Spec.NamespaceOptions == nil {
return warnings
}
//nolint:staticcheck
if tnt.Spec.NamespaceOptions.AdditionalMetadata != nil {
warnings = append(warnings,
"The field `additionalMetadata` is deprecated and will be removed in a future release. Please migrate to rules metadata. See: https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace.",
)
}
//nolint:staticcheck
if len(tnt.Spec.NamespaceOptions.AdditionalMetadataList) > 0 {
warnings = append(warnings,
"The field `additionalMetadataList` is deprecated and will be removed in a future release. Please migrate to rules metadata. See: https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace.",
)
}
//nolint:staticcheck
if tnt.Spec.NamespaceOptions.RequiredMetadata != nil {
warnings = append(warnings,
"The field `requiredMetadata` is deprecated and will be removed in a future release. Please migrate to rules metadata. See: https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace.",
)
}
//nolint:staticcheck
if len(tnt.Spec.NamespaceOptions.ForbiddenLabels.Exact) > 0 ||
tnt.Spec.NamespaceOptions.ForbiddenLabels.Regex != "" {
warnings = append(warnings,
"The field `forbiddenLabels` is deprecated and will be removed in a future release. Please migrate to rules metadata. See: https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace.",
)
}
//nolint:staticcheck
if len(tnt.Spec.NamespaceOptions.ForbiddenAnnotations.Exact) > 0 ||
tnt.Spec.NamespaceOptions.ForbiddenAnnotations.Regex != "" {
warnings = append(warnings,
"The field `forbiddenAnnotations` is deprecated and will be removed in a future release. Please migrate to rules metadata. See: https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace.",
)
}
return warnings
}
@@ -0,0 +1,142 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
import (
"strings"
"testing"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api"
)
//nolint:staticcheck
func TestDeprecatedTenantFieldsWarnings(t *testing.T) {
t.Parallel()
tests := []struct {
name string
configure func(*capsulev1beta2.Tenant)
field string
}{
{
name: "resource quota items",
configure: func(tnt *capsulev1beta2.Tenant) {
tnt.Spec.ResourceQuota.Items = []corev1.ResourceQuotaSpec{{}}
},
field: "`resourceQuotas`",
},
{
name: "resource quota scope",
configure: func(tnt *capsulev1beta2.Tenant) {
tnt.Spec.ResourceQuota.Scope = api.ResourceQuotaScopeTenant
},
field: "`resourceQuotas`",
},
{
name: "service options",
configure: func(tnt *capsulev1beta2.Tenant) {
tnt.Spec.ServiceOptions = &api.ServiceOptions{}
},
field: "`serviceOptions`",
},
{
name: "pod options",
configure: func(tnt *capsulev1beta2.Tenant) {
tnt.Spec.PodOptions = &api.PodOptions{}
},
field: "`podOptions`",
},
{
name: "additional metadata list",
configure: func(tnt *capsulev1beta2.Tenant) {
tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{
AdditionalMetadataList: []api.AdditionalMetadataSelectorSpec{{}},
}
},
field: "`additionalMetadataList`",
},
{
name: "required metadata",
configure: func(tnt *capsulev1beta2.Tenant) {
tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{
RequiredMetadata: &capsulev1beta2.RequiredMetadata{},
}
},
field: "`requiredMetadata`",
},
{
name: "forbidden labels exact",
configure: func(tnt *capsulev1beta2.Tenant) {
tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{
ForbiddenLabels: api.ForbiddenListSpec{Exact: []string{"blocked"}},
}
},
field: "`forbiddenLabels`",
},
{
name: "forbidden labels regex",
configure: func(tnt *capsulev1beta2.Tenant) {
tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{
ForbiddenLabels: api.ForbiddenListSpec{Regex: "blocked-.*"},
}
},
field: "`forbiddenLabels`",
},
{
name: "forbidden annotations exact",
configure: func(tnt *capsulev1beta2.Tenant) {
tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{
ForbiddenAnnotations: api.ForbiddenListSpec{Exact: []string{"blocked"}},
}
},
field: "`forbiddenAnnotations`",
},
{
name: "forbidden annotations regex",
configure: func(tnt *capsulev1beta2.Tenant) {
tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{
ForbiddenAnnotations: api.ForbiddenListSpec{Regex: "blocked-.*"},
}
},
field: "`forbiddenAnnotations`",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tnt := &capsulev1beta2.Tenant{}
tt.configure(tnt)
response := (&warningHandler{}).handle(tnt, admission.Request{})
if len(response.Warnings) != 1 {
t.Fatalf("warnings = %v, want exactly one warning", response.Warnings)
}
if !strings.Contains(response.Warnings[0], tt.field) {
t.Fatalf("warning = %q, want field %s", response.Warnings[0], tt.field)
}
})
}
}
func TestDeprecatedTenantFieldsWarningsAreAbsentForZeroValues(t *testing.T) {
t.Parallel()
tnt := &capsulev1beta2.Tenant{
Spec: capsulev1beta2.TenantSpec{
NamespaceOptions: &capsulev1beta2.NamespaceOptions{},
},
}
response := (&warningHandler{}).handle(tnt, admission.Request{})
if len(response.Warnings) != 0 {
t.Fatalf("warnings = %v, want no warnings", response.Warnings)
}
}