Files
Aleksei Sviridkin ef491f5ee8 fix(api): keep the MutatingAdmissionPolicy plugin out of admission
From k8s.io/apiserver v0.36 the MutatingAdmissionPolicy feature is GA
and on by default, and the plugin sits in the recommended admission
order, so cozystack-api would run it. The plugin refuses every write
with "not yet ready to handle request" until its informer has synced
mutatingadmissionpolicies. This server has no RBAC to list them, and
clusters older than 1.36 do not serve the resource at all, so every
create, update and delete on its resources would fail.

Disable the plugin in the default options, and again in Complete,
because --disable-admission-plugins replaces the default list instead
of adding to it. Naming the plugin in --enable-admission-plugins still
turns it on: that operator has granted it what it needs, and leaving it
on both lists would fail validation. On the v0.35 libraries the feature
gate already keeps the plugin off, so nothing changes today.

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

246 lines
8.3 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
import (
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/spf13/pflag"
mutatingadmissionpolicy "k8s.io/apiserver/pkg/admission/plugin/policy/mutating"
)
// validHelmReleaseFlags returns a CozyServerOptions populated with the same
// defaults NewCozyServerOptions sets, so each test case mutates a single
// flag and asserts the resulting validation behaviour in isolation.
func validHelmReleaseFlags() *CozyServerOptions {
return &CozyServerOptions{
HelmReleaseInterval: "5m",
HelmReleaseRetryInterval: "30s",
HelmReleaseInstallTimeout: "10m",
HelmReleaseUpgradeTimeout: "10m",
HelmReleaseMaxHistory: 5,
}
}
// Pins that each malformed/non-positive duration and a negative MaxHistory
// is rejected at startup. Without this, a regression that swaps `<` for
// `<=` on the MaxHistory branch — breaking the documented "0 means
// unlimited" contract — or that drops a `return err` line on any of the
// four duration branches would ship green.
func TestCozyServerOptions_ParseAndValidateHelmReleaseFlags_Rejects(t *testing.T) {
cases := []struct {
name string
mutate func(o *CozyServerOptions)
wantSubs string
}{
{
name: "interval zero",
mutate: func(o *CozyServerOptions) { o.HelmReleaseInterval = "0s" },
wantSubs: "--helmrelease-interval",
},
{
name: "interval negative",
mutate: func(o *CozyServerOptions) { o.HelmReleaseInterval = "-5m" },
wantSubs: "--helmrelease-interval",
},
{
name: "interval malformed",
mutate: func(o *CozyServerOptions) { o.HelmReleaseInterval = "5x" },
wantSubs: "--helmrelease-interval",
},
{
name: "retry-interval empty",
mutate: func(o *CozyServerOptions) { o.HelmReleaseRetryInterval = "" },
wantSubs: "--helmrelease-retry-interval",
},
{
name: "install-timeout zero",
mutate: func(o *CozyServerOptions) { o.HelmReleaseInstallTimeout = "0s" },
wantSubs: "--helmrelease-install-timeout",
},
{
name: "upgrade-timeout malformed",
mutate: func(o *CozyServerOptions) { o.HelmReleaseUpgradeTimeout = "tenminutes" },
wantSubs: "--helmrelease-upgrade-timeout",
},
{
name: "max-history negative",
mutate: func(o *CozyServerOptions) { o.HelmReleaseMaxHistory = -1 },
wantSubs: "--helmrelease-max-history must be >= 0",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
o := validHelmReleaseFlags()
tc.mutate(o)
_, err := o.parseAndValidateHelmReleaseFlags()
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), tc.wantSubs) {
t.Errorf("error %q does not contain %q", err.Error(), tc.wantSubs)
}
})
}
}
// Pins the happy path: valid flag strings parse to the expected
// time.Duration values and MaxHistory=0 is accepted (the documented
// "unlimited per Helm semantics" branch).
func TestCozyServerOptions_ParseAndValidateHelmReleaseFlags_Accepts(t *testing.T) {
cases := []struct {
name string
o *CozyServerOptions
wantInterval time.Duration
wantRetry time.Duration
wantInstall time.Duration
wantUpgrade time.Duration
wantMaxHistory int
}{
{
name: "production defaults",
o: validHelmReleaseFlags(),
wantInterval: 5 * time.Minute,
wantRetry: 30 * time.Second,
wantInstall: 10 * time.Minute,
wantUpgrade: 10 * time.Minute,
wantMaxHistory: 5,
},
{
name: "max-history zero accepted as unlimited",
o: func() *CozyServerOptions {
o := validHelmReleaseFlags()
o.HelmReleaseMaxHistory = 0
return o
}(),
wantInterval: 5 * time.Minute,
wantRetry: 30 * time.Second,
wantInstall: 10 * time.Minute,
wantUpgrade: 10 * time.Minute,
wantMaxHistory: 0,
},
{
name: "asymmetric install/upgrade timeouts both honored",
o: func() *CozyServerOptions {
o := validHelmReleaseFlags()
o.HelmReleaseInstallTimeout = "8m"
o.HelmReleaseUpgradeTimeout = "12m"
return o
}(),
wantInterval: 5 * time.Minute,
wantRetry: 30 * time.Second,
wantInstall: 8 * time.Minute,
wantUpgrade: 12 * time.Minute,
wantMaxHistory: 5,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
v, err := tc.o.parseAndValidateHelmReleaseFlags()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v.interval != tc.wantInterval {
t.Errorf("interval = %v, want %v", v.interval, tc.wantInterval)
}
if v.retryInterval != tc.wantRetry {
t.Errorf("retryInterval = %v, want %v", v.retryInterval, tc.wantRetry)
}
if v.installTimeout != tc.wantInstall {
t.Errorf("installTimeout = %v, want %v", v.installTimeout, tc.wantInstall)
}
if v.upgradeTimeout != tc.wantUpgrade {
t.Errorf("upgradeTimeout = %v, want %v", v.upgradeTimeout, tc.wantUpgrade)
}
if v.maxHistory != tc.wantMaxHistory {
t.Errorf("maxHistory = %d, want %d", v.maxHistory, tc.wantMaxHistory)
}
})
}
}
// Pins that NewCozyServerOptions seeds defaults that pass validation, so
// `cozystack-api ...` with no HelmRelease flags overridden does not fail
// at startup. Guards against a future change to defaults that accidentally
// violates the validator.
func TestNewCozyServerOptions_DefaultsValidate(t *testing.T) {
o := NewCozyServerOptions(nil, nil)
if _, err := o.parseAndValidateHelmReleaseFlags(); err != nil {
t.Fatalf("default options must validate, got: %v", err)
}
}
func TestNewCozyServerOptions_DisablesMutatingAdmissionPolicy(t *testing.T) {
o := NewCozyServerOptions(nil, nil)
if !slices.Contains(o.RecommendedOptions.Admission.DisablePlugins, mutatingadmissionpolicy.PluginName) {
t.Fatalf("%s is not disabled, disabled plugins: %v",
mutatingadmissionpolicy.PluginName, o.RecommendedOptions.Admission.DisablePlugins)
}
}
// --disable-admission-plugins replaces the default list rather than adding
// to it; Complete must put the plugin back before anything is built.
func TestComplete_KeepsMutatingAdmissionPolicyDisabledAfterFlagOverride(t *testing.T) {
t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "missing"))
t.Setenv("HOME", t.TempDir())
t.Setenv("KUBERNETES_SERVICE_HOST", "")
o := NewCozyServerOptions(nil, nil)
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
o.RecommendedOptions.AddFlags(fs)
if err := fs.Parse([]string{"--disable-admission-plugins=NamespaceLifecycle"}); err != nil {
t.Fatal(err)
}
// Complete stops at the missing kubeconfig; the admission list is
// settled before that.
_ = o.Complete()
got := o.RecommendedOptions.Admission.DisablePlugins
if !slices.Contains(got, mutatingadmissionpolicy.PluginName) || !slices.Contains(got, "NamespaceLifecycle") {
t.Fatalf("disabled plugins = %v, want both NamespaceLifecycle and %s", got, mutatingadmissionpolicy.PluginName)
}
}
// An operator who enables the plugin by name has given it what it needs;
// keeping it on the disable list too would fail validation as a conflict.
func TestComplete_ExplicitEnableOfMutatingAdmissionPolicyWins(t *testing.T) {
t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "missing"))
t.Setenv("HOME", t.TempDir())
t.Setenv("KUBERNETES_SERVICE_HOST", "")
o := NewCozyServerOptions(nil, nil)
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
o.RecommendedOptions.AddFlags(fs)
if err := fs.Parse([]string{"--enable-admission-plugins=" + mutatingadmissionpolicy.PluginName}); err != nil {
t.Fatal(err)
}
_ = o.Complete()
if got := o.RecommendedOptions.Admission.DisablePlugins; slices.Contains(got, mutatingadmissionpolicy.PluginName) {
t.Fatalf("disabled plugins = %v, want %s left enabled", got, mutatingadmissionpolicy.PluginName)
}
if errs := o.RecommendedOptions.Admission.Validate(); len(errs) > 0 {
t.Fatalf("admission options do not validate: %v", errs)
}
}