Skip to content

Commit c4bd31a

Browse files
davidxiaclaude
andcommitted
feat: support configurable PodDisruptionBudget
Adds three new SpiceDBCluster config fields: - `pdbDisabled` (bool): disables PDB creation entirely; any existing PDB owned by the cluster is deleted on the next reconcile - `pdbMaxUnavailable` (int or percentage string, e.g. "2" or "50%"): sets the PDB's maxUnavailable field; defaults to 1 when neither field is set - `pdbMinAvailable` (int or percentage string): sets the PDB's minAvailable field; mutually exclusive with pdbMaxUnavailable Fixes #414 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9bb3af9 commit c4bd31a

3 files changed

Lines changed: 245 additions & 16 deletions

File tree

pkg/config/config.go

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ var (
9393
dashboardTLSKeyPathKey = newKey("dashboardTLSKeyPath", DefaultTLSKeyFile)
9494
dashboardTLSCertPathKey = newKey("dashboardTLSCertPath", DefaultTLSCrtFile)
9595
skipTLSWarningKey = newBoolOrStringKey("skipTLSWarning", false)
96+
pdbKey = "pdb"
97+
pdbDisabledSubKey = newBoolOrStringKey("disabled", false)
98+
pdbMaxUnavailableSubKey = newStringKey("maxUnavailable")
99+
pdbMinAvailableSubKey = newStringKey("minAvailable")
96100
)
97101

98102
// Warning is an issue with configuration that we will report as undesirable
@@ -185,6 +189,14 @@ type SpiceConfig struct {
185189
ProjectLabels bool
186190
ProjectAnnotations bool
187191
Passthrough map[string]string
192+
PDB PDBConfig
193+
}
194+
195+
// PDBConfig holds the configuration for the PodDisruptionBudget.
196+
type PDBConfig struct {
197+
Disabled bool
198+
MaxUnavailable *intstr.IntOrString
199+
MinAvailable *intstr.IntOrString
188200
}
189201

190202
// NewConfig checks that the values in the config + the secrets are sane
@@ -427,6 +439,39 @@ func NewConfig(cluster *v1alpha1.SpiceDBCluster, globalConfig *OperatorConfig, s
427439
warnings = append(warnings, saAnnotationWarnings...)
428440
}
429441

442+
if pdbRaw, ok := config[pdbKey]; ok {
443+
delete(config, pdbKey)
444+
pdbMap, ok := pdbRaw.(map[string]any)
445+
if !ok {
446+
errs = append(errs, fmt.Errorf("expected object for key %q", pdbKey))
447+
} else {
448+
pdbConfig := RawConfig(pdbMap)
449+
450+
spiceConfig.PDB.Disabled, err = pdbDisabledSubKey.pop(pdbConfig)
451+
if err != nil {
452+
errs = append(errs, err)
453+
}
454+
455+
if s := pdbMaxUnavailableSubKey.pop(pdbConfig); s != "" {
456+
val := parseIntOrStringValue(s)
457+
spiceConfig.PDB.MaxUnavailable = &val
458+
}
459+
460+
if s := pdbMinAvailableSubKey.pop(pdbConfig); s != "" {
461+
val := parseIntOrStringValue(s)
462+
spiceConfig.PDB.MinAvailable = &val
463+
}
464+
465+
if spiceConfig.PDB.MaxUnavailable != nil && spiceConfig.PDB.MinAvailable != nil {
466+
errs = append(errs, fmt.Errorf("only one of pdb.maxUnavailable or pdb.minAvailable can be set"))
467+
}
468+
469+
for k := range pdbConfig {
470+
warnings = append(warnings, fmt.Errorf("unknown key %q in pdb config", k))
471+
}
472+
}
473+
}
474+
430475
// generate secret refs for tls if specified
431476
if len(spiceConfig.TLSSecretName) > 0 {
432477
passthroughKeys := []*key[string]{
@@ -981,15 +1026,23 @@ func (c *Config) PodDisruptionBudget() *applypolicyv1.PodDisruptionBudgetApplyCo
9811026
}
9821027

9831028
func (c *Config) unpatchedPDB() *applypolicyv1.PodDisruptionBudgetApplyConfiguration {
1029+
spec := applypolicyv1.PodDisruptionBudgetSpec().
1030+
WithSelector(applymetav1.LabelSelector().WithMatchLabels(
1031+
map[string]string{metadata.KubernetesInstanceLabelKey: deploymentName(c.Name)},
1032+
))
1033+
1034+
switch {
1035+
case c.PDB.MaxUnavailable != nil:
1036+
spec = spec.WithMaxUnavailable(*c.PDB.MaxUnavailable)
1037+
case c.PDB.MinAvailable != nil:
1038+
spec = spec.WithMinAvailable(*c.PDB.MinAvailable)
1039+
default:
1040+
spec = spec.WithMaxUnavailable(intstr.FromInt32(1))
1041+
}
1042+
9841043
return applypolicyv1.PodDisruptionBudget(pdbName(c.Name), c.Namespace).
9851044
WithLabels(metadata.LabelsForComponent(c.Name, metadata.ComponentPDBLabel)).
986-
WithSpec(applypolicyv1.PodDisruptionBudgetSpec().
987-
WithSelector(applymetav1.LabelSelector().WithMatchLabels(
988-
map[string]string{metadata.KubernetesInstanceLabelKey: deploymentName(c.Name)},
989-
)).
990-
// only allow one pod to be unavailable at a time
991-
WithMaxUnavailable(intstr.FromInt32(1)),
992-
)
1045+
WithSpec(spec)
9931046
}
9941047

9951048
func (c *Config) commonLabels(name string) map[string]string {
@@ -1107,3 +1160,12 @@ func deploymentName(name string) string {
11071160
func pdbName(name string) string {
11081161
return fmt.Sprintf("%s-spicedb", name)
11091162
}
1163+
1164+
// parseIntOrStringValue parses a string as an integer or, if that fails,
1165+
// returns it as a string-typed IntOrString (e.g. for percentage values like "50%").
1166+
func parseIntOrStringValue(s string) intstr.IntOrString {
1167+
if v, err := strconv.ParseInt(s, 10, 32); err == nil {
1168+
return intstr.FromInt32(int32(v))
1169+
}
1170+
return intstr.FromString(s)
1171+
}

pkg/config/config_test.go

Lines changed: 147 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3183,9 +3183,11 @@ metadata:
31833183
func TestPDB(t *testing.T) {
31843184
resources := newFakeResources()
31853185
tests := []struct {
3186-
name string
3187-
cluster v1alpha1.ClusterSpec
3188-
wantPDB *applypolicyv1.PodDisruptionBudgetApplyConfiguration
3186+
name string
3187+
cluster v1alpha1.ClusterSpec
3188+
wantPDB *applypolicyv1.PodDisruptionBudgetApplyConfiguration
3189+
wantPDBDisabled bool
3190+
wantErr error
31893191
}{
31903192
{
31913193
name: "pdb sets maxUnavailable to 1",
@@ -3257,6 +3259,136 @@ metadata:
32573259
}),
32583260
).WithMaxUnavailable(intstr.FromInt32(1))),
32593261
},
3262+
{
3263+
name: "pdb disabled",
3264+
cluster: v1alpha1.ClusterSpec{
3265+
SecretRef: "test-secret",
3266+
Config: json.RawMessage(`
3267+
{
3268+
"datastoreEngine": "cockroachdb",
3269+
"pdb": {
3270+
"disabled": true
3271+
}
3272+
}
3273+
`),
3274+
},
3275+
wantPDBDisabled: true,
3276+
},
3277+
{
3278+
name: "pdb with custom maxUnavailable integer",
3279+
cluster: v1alpha1.ClusterSpec{
3280+
SecretRef: "test-secret",
3281+
Config: json.RawMessage(`
3282+
{
3283+
"datastoreEngine": "cockroachdb",
3284+
"pdb": {
3285+
"maxUnavailable": "2"
3286+
}
3287+
}
3288+
`),
3289+
},
3290+
wantPDB: applypolicyv1.PodDisruptionBudget("test-spicedb", "test").
3291+
WithLabels(metadata.LabelsForComponent("test", metadata.ComponentPDBLabel)).
3292+
WithLabels(map[string]string{
3293+
metadata.KubernetesInstanceLabelKey: "test-spicedb",
3294+
metadata.KubernetesNameLabelKey: "test-spicedb",
3295+
metadata.KubernetesComponentLabelKey: metadata.ComponentSpiceDBLabelValue,
3296+
metadata.KubernetesVersionLabelKey: "v1",
3297+
}).
3298+
WithOwnerReferences(applymetav1.OwnerReference().
3299+
WithName("test").
3300+
WithKind(v1alpha1.SpiceDBClusterKind).
3301+
WithAPIVersion(v1alpha1.SchemeGroupVersion.String()).
3302+
WithUID("1")).
3303+
WithSpec(
3304+
applypolicyv1.PodDisruptionBudgetSpec().WithSelector(
3305+
applymetav1.LabelSelector().WithMatchLabels(map[string]string{
3306+
metadata.KubernetesInstanceLabelKey: "test-spicedb",
3307+
}),
3308+
).WithMaxUnavailable(intstr.FromInt32(2))),
3309+
},
3310+
{
3311+
name: "pdb with custom maxUnavailable percentage",
3312+
cluster: v1alpha1.ClusterSpec{
3313+
SecretRef: "test-secret",
3314+
Config: json.RawMessage(`
3315+
{
3316+
"datastoreEngine": "cockroachdb",
3317+
"pdb": {
3318+
"maxUnavailable": "50%"
3319+
}
3320+
}
3321+
`),
3322+
},
3323+
wantPDB: applypolicyv1.PodDisruptionBudget("test-spicedb", "test").
3324+
WithLabels(metadata.LabelsForComponent("test", metadata.ComponentPDBLabel)).
3325+
WithLabels(map[string]string{
3326+
metadata.KubernetesInstanceLabelKey: "test-spicedb",
3327+
metadata.KubernetesNameLabelKey: "test-spicedb",
3328+
metadata.KubernetesComponentLabelKey: metadata.ComponentSpiceDBLabelValue,
3329+
metadata.KubernetesVersionLabelKey: "v1",
3330+
}).
3331+
WithOwnerReferences(applymetav1.OwnerReference().
3332+
WithName("test").
3333+
WithKind(v1alpha1.SpiceDBClusterKind).
3334+
WithAPIVersion(v1alpha1.SchemeGroupVersion.String()).
3335+
WithUID("1")).
3336+
WithSpec(
3337+
applypolicyv1.PodDisruptionBudgetSpec().WithSelector(
3338+
applymetav1.LabelSelector().WithMatchLabels(map[string]string{
3339+
metadata.KubernetesInstanceLabelKey: "test-spicedb",
3340+
}),
3341+
).WithMaxUnavailable(intstr.FromString("50%"))),
3342+
},
3343+
{
3344+
name: "pdb with custom minAvailable",
3345+
cluster: v1alpha1.ClusterSpec{
3346+
SecretRef: "test-secret",
3347+
Config: json.RawMessage(`
3348+
{
3349+
"datastoreEngine": "cockroachdb",
3350+
"pdb": {
3351+
"minAvailable": "3"
3352+
}
3353+
}
3354+
`),
3355+
},
3356+
wantPDB: applypolicyv1.PodDisruptionBudget("test-spicedb", "test").
3357+
WithLabels(metadata.LabelsForComponent("test", metadata.ComponentPDBLabel)).
3358+
WithLabels(map[string]string{
3359+
metadata.KubernetesInstanceLabelKey: "test-spicedb",
3360+
metadata.KubernetesNameLabelKey: "test-spicedb",
3361+
metadata.KubernetesComponentLabelKey: metadata.ComponentSpiceDBLabelValue,
3362+
metadata.KubernetesVersionLabelKey: "v1",
3363+
}).
3364+
WithOwnerReferences(applymetav1.OwnerReference().
3365+
WithName("test").
3366+
WithKind(v1alpha1.SpiceDBClusterKind).
3367+
WithAPIVersion(v1alpha1.SchemeGroupVersion.String()).
3368+
WithUID("1")).
3369+
WithSpec(
3370+
applypolicyv1.PodDisruptionBudgetSpec().WithSelector(
3371+
applymetav1.LabelSelector().WithMatchLabels(map[string]string{
3372+
metadata.KubernetesInstanceLabelKey: "test-spicedb",
3373+
}),
3374+
).WithMinAvailable(intstr.FromInt32(3))),
3375+
},
3376+
{
3377+
name: "pdb with both maxUnavailable and minAvailable returns error",
3378+
cluster: v1alpha1.ClusterSpec{
3379+
SecretRef: "test-secret",
3380+
Config: json.RawMessage(`
3381+
{
3382+
"datastoreEngine": "cockroachdb",
3383+
"pdb": {
3384+
"maxUnavailable": "1",
3385+
"minAvailable": "1"
3386+
}
3387+
}
3388+
`),
3389+
},
3390+
wantErr: fmt.Errorf("only one of pdb.maxUnavailable or pdb.minAvailable can be set"),
3391+
},
32603392
}
32613393
for _, tt := range tests {
32623394
t.Run(tt.name, func(t *testing.T) {
@@ -3273,14 +3405,21 @@ metadata:
32733405
Spec: tt.cluster,
32743406
}
32753407
got, _, err := NewConfig(cluster, ptr.To(testGlobalConfig.Copy()), singleSecretMap("test-secret", secret), resources)
3408+
if tt.wantErr != nil {
3409+
require.ErrorContains(t, err, tt.wantErr.Error())
3410+
return
3411+
}
32763412
require.NoError(t, err)
32773413

3278-
wantPDB, err := json.Marshal(tt.wantPDB)
3279-
require.NoError(t, err)
3280-
gotPDB, err := json.Marshal(got.PodDisruptionBudget())
3281-
require.NoError(t, err)
3414+
require.Equal(t, tt.wantPDBDisabled, got.PDB.Disabled)
32823415

3283-
require.JSONEq(t, string(wantPDB), string(gotPDB))
3416+
if tt.wantPDB != nil {
3417+
wantPDB, err := json.Marshal(tt.wantPDB)
3418+
require.NoError(t, err)
3419+
gotPDB, err := json.Marshal(got.PodDisruptionBudget())
3420+
require.NoError(t, err)
3421+
require.JSONEq(t, string(wantPDB), string(gotPDB))
3422+
}
32843423
})
32853424
}
32863425
}

pkg/controller/controller.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -557,9 +557,28 @@ func (c *Controller) ensurePDB(next ...handler.Handler) handler.Handler {
557557
},
558558
)
559559

560+
cfg := CtxConfig.MustValue(ctx)
561+
562+
// If PDB is disabled, delete any existing PDB and skip creation
563+
if cfg.PDB.Disabled {
564+
clusterName := CtxClusterNN.MustValue(ctx).Name
565+
expectedLabels := metadata.LabelsForComponent(clusterName, metadata.ComponentPDBLabel)
566+
for _, pdb := range pdbComponent.List(ctx, CtxClusterNN.MustValue(ctx)) {
567+
if !hasAllLabels(pdb.Labels, expectedLabels) {
568+
logr.FromContextOrDiscard(ctx).V(4).Info("skipping pdb deletion: missing operator labels", "namespace", pdb.Namespace, "name", pdb.Name)
569+
continue
570+
}
571+
nn := types.NamespacedName{Name: pdb.Name, Namespace: pdb.Namespace}
572+
if err := deletePDB(ctx, nn); err != nil && !apierrors.IsNotFound(err) {
573+
logr.FromContextOrDiscard(ctx).Error(err, "error deleting pdb")
574+
}
575+
}
576+
handler.Handlers(next).MustOne().Handle(ctx)
577+
return
578+
}
579+
560580
// build the handler that ensures the PDB with the proper definition
561581
// exists and run it
562-
cfg := CtxConfig.MustValue(ctx)
563582
component.NewEnsureComponentByHash(
564583
component.NewHashableComponent(pdbComponent, hash.NewObjectHash(), "authzed.com/controller-component-hash"),
565584
CtxClusterNN,
@@ -954,3 +973,12 @@ func (c *Controller) ensureService(next ...handler.Handler) handler.Handler {
954973
handler.Handlers(next).MustOne().Handle(ctx)
955974
}, "ensureService")
956975
}
976+
977+
func hasAllLabels(actual, expected map[string]string) bool {
978+
for k, v := range expected {
979+
if actual[k] != v {
980+
return false
981+
}
982+
}
983+
return true
984+
}

0 commit comments

Comments
 (0)