Skip to content

Commit 3d8de88

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 3d8de88

3 files changed

Lines changed: 225 additions & 16 deletions

File tree

pkg/config/config.go

Lines changed: 64 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,9 @@ type SpiceConfig struct {
185189
ProjectLabels bool
186190
ProjectAnnotations bool
187191
Passthrough map[string]string
192+
PDBDisabled bool
193+
PDBMaxUnavailable *intstr.IntOrString
194+
PDBMinAvailable *intstr.IntOrString
188195
}
189196

190197
// NewConfig checks that the values in the config + the secrets are sane
@@ -427,6 +434,39 @@ func NewConfig(cluster *v1alpha1.SpiceDBCluster, globalConfig *OperatorConfig, s
427434
warnings = append(warnings, saAnnotationWarnings...)
428435
}
429436

437+
if pdbRaw, ok := config[pdbKey]; ok {
438+
delete(config, pdbKey)
439+
pdbMap, ok := pdbRaw.(map[string]any)
440+
if !ok {
441+
errs = append(errs, fmt.Errorf("expected object for key %q", pdbKey))
442+
} else {
443+
pdbConfig := RawConfig(pdbMap)
444+
445+
spiceConfig.PDBDisabled, err = pdbDisabledSubKey.pop(pdbConfig)
446+
if err != nil {
447+
errs = append(errs, err)
448+
}
449+
450+
if s := pdbMaxUnavailableSubKey.pop(pdbConfig); s != "" {
451+
val := parseIntOrStringValue(s)
452+
spiceConfig.PDBMaxUnavailable = &val
453+
}
454+
455+
if s := pdbMinAvailableSubKey.pop(pdbConfig); s != "" {
456+
val := parseIntOrStringValue(s)
457+
spiceConfig.PDBMinAvailable = &val
458+
}
459+
460+
if spiceConfig.PDBMaxUnavailable != nil && spiceConfig.PDBMinAvailable != nil {
461+
errs = append(errs, fmt.Errorf("only one of pdb.maxUnavailable or pdb.minAvailable can be set"))
462+
}
463+
464+
for k := range pdbConfig {
465+
warnings = append(warnings, fmt.Errorf("unknown key %q in pdb config", k))
466+
}
467+
}
468+
}
469+
430470
// generate secret refs for tls if specified
431471
if len(spiceConfig.TLSSecretName) > 0 {
432472
passthroughKeys := []*key[string]{
@@ -981,15 +1021,23 @@ func (c *Config) PodDisruptionBudget() *applypolicyv1.PodDisruptionBudgetApplyCo
9811021
}
9821022

9831023
func (c *Config) unpatchedPDB() *applypolicyv1.PodDisruptionBudgetApplyConfiguration {
1024+
spec := applypolicyv1.PodDisruptionBudgetSpec().
1025+
WithSelector(applymetav1.LabelSelector().WithMatchLabels(
1026+
map[string]string{metadata.KubernetesInstanceLabelKey: deploymentName(c.Name)},
1027+
))
1028+
1029+
switch {
1030+
case c.PDBMaxUnavailable != nil:
1031+
spec = spec.WithMaxUnavailable(*c.PDBMaxUnavailable)
1032+
case c.PDBMinAvailable != nil:
1033+
spec = spec.WithMinAvailable(*c.PDBMinAvailable)
1034+
default:
1035+
spec = spec.WithMaxUnavailable(intstr.FromInt32(1))
1036+
}
1037+
9841038
return applypolicyv1.PodDisruptionBudget(pdbName(c.Name), c.Namespace).
9851039
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-
)
1040+
WithSpec(spec)
9931041
}
9941042

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

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.PDBDisabled)
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: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -557,9 +557,22 @@ 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.PDBDisabled {
564+
for _, pdb := range pdbComponent.List(ctx, CtxClusterNN.MustValue(ctx)) {
565+
nn := types.NamespacedName{Name: pdb.Name, Namespace: pdb.Namespace}
566+
if err := deletePDB(ctx, nn); err != nil && !apierrors.IsNotFound(err) {
567+
logr.FromContextOrDiscard(ctx).Error(err, "error deleting pdb")
568+
}
569+
}
570+
handler.Handlers(next).MustOne().Handle(ctx)
571+
return
572+
}
573+
560574
// build the handler that ensures the PDB with the proper definition
561575
// exists and run it
562-
cfg := CtxConfig.MustValue(ctx)
563576
component.NewEnsureComponentByHash(
564577
component.NewHashableComponent(pdbComponent, hash.NewObjectHash(), "authzed.com/controller-component-hash"),
565578
CtxClusterNN,

0 commit comments

Comments
 (0)