diff --git a/api/product/disruption_budget.go b/api/product/disruption_budget.go index ffb65ad0..c6c03852 100644 --- a/api/product/disruption_budget.go +++ b/api/product/disruption_budget.go @@ -1,6 +1,8 @@ package product import ( + "fmt" + policyv1 "k8s.io/api/policy/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" @@ -42,3 +44,29 @@ func DefineDisruptionBudget(p NamerAndOwnerProvider, req ctrl.Request, minAvaila return pdb } + +// DefineSessionDisruptionBudget creates a PodDisruptionBudget for workbench session pods. +// This PDB uses maxUnavailable=0 to prevent any session pods from being evicted during +// node drains or cluster maintenance, ensuring session persistence. +func DefineSessionDisruptionBudget(p NamerAndOwnerProvider, req ctrl.Request) *policyv1.PodDisruptionBudget { + return &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-sessions", p.ComponentName()), + Namespace: req.Namespace, + OwnerReferences: p.OwnerReferencesForChildren(), + Labels: p.KubernetesLabels(), + }, + Spec: policyv1.PodDisruptionBudgetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + // This label is set by the launcher on all session pods + "launcher-instance-id": p.ComponentName(), + }, + }, + MaxUnavailable: &intstr.IntOrString{ + Type: intstr.Int, + IntVal: 0, + }, + }, + } +} diff --git a/client-go/applyconfiguration/core/v1beta1/connectruntimeimagespec.go b/client-go/applyconfiguration/core/v1beta1/connectruntimeimagespec.go new file mode 100644 index 00000000..29cf31e7 --- /dev/null +++ b/client-go/applyconfiguration/core/v1beta1/connectruntimeimagespec.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023-2026 Posit Software, PBC + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +// ConnectRuntimeImageSpecApplyConfiguration represents a declarative configuration of the ConnectRuntimeImageSpec type for use +// with apply. +type ConnectRuntimeImageSpecApplyConfiguration struct { + RVersion *string `json:"rVersion,omitempty"` + PyVersion *string `json:"pyVersion,omitempty"` + OSVersion *string `json:"osVersion,omitempty"` + QuartoVersion *string `json:"quartoVersion,omitempty"` + Repo *string `json:"repo,omitempty"` +} + +// ConnectRuntimeImageSpecApplyConfiguration constructs a declarative configuration of the ConnectRuntimeImageSpec type for use with +// apply. +func ConnectRuntimeImageSpec() *ConnectRuntimeImageSpecApplyConfiguration { + return &ConnectRuntimeImageSpecApplyConfiguration{} +} + +// WithRVersion sets the RVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the RVersion field is set to the value of the last call. +func (b *ConnectRuntimeImageSpecApplyConfiguration) WithRVersion(value string) *ConnectRuntimeImageSpecApplyConfiguration { + b.RVersion = &value + return b +} + +// WithPyVersion sets the PyVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the PyVersion field is set to the value of the last call. +func (b *ConnectRuntimeImageSpecApplyConfiguration) WithPyVersion(value string) *ConnectRuntimeImageSpecApplyConfiguration { + b.PyVersion = &value + return b +} + +// WithOSVersion sets the OSVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the OSVersion field is set to the value of the last call. +func (b *ConnectRuntimeImageSpecApplyConfiguration) WithOSVersion(value string) *ConnectRuntimeImageSpecApplyConfiguration { + b.OSVersion = &value + return b +} + +// WithQuartoVersion sets the QuartoVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the QuartoVersion field is set to the value of the last call. +func (b *ConnectRuntimeImageSpecApplyConfiguration) WithQuartoVersion(value string) *ConnectRuntimeImageSpecApplyConfiguration { + b.QuartoVersion = &value + return b +} + +// WithRepo sets the Repo field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Repo field is set to the value of the last call. +func (b *ConnectRuntimeImageSpecApplyConfiguration) WithRepo(value string) *ConnectRuntimeImageSpecApplyConfiguration { + b.Repo = &value + return b +} diff --git a/internal/controller/core/connect.go b/internal/controller/core/connect.go index 366a4726..4a17aeb1 100644 --- a/internal/controller/core/connect.go +++ b/internal/controller/core/connect.go @@ -822,9 +822,8 @@ func (r *ConnectReconciler) ensureDeployedService(ctx context.Context, req ctrl. } // POD DISRUPTION BUDGET - if err := CreateOrUpdateDisruptionBudget( - ctx, req, r.Client, r.Scheme, c, c, ptr.To(product.DetermineMinAvailableReplicas(c.Spec.Replicas)), nil, - ); err != nil { + pdb := product.DefineDisruptionBudget(c, req, ptr.To(product.DetermineMinAvailableReplicas(c.Spec.Replicas)), nil) + if err := CreateOrUpdateDisruptionBudget(ctx, r.Client, r.Scheme, c, pdb); err != nil { return ctrl.Result{}, err } diff --git a/internal/controller/core/disruption_budget.go b/internal/controller/core/disruption_budget.go index 493cde87..1a77b569 100644 --- a/internal/controller/core/disruption_budget.go +++ b/internal/controller/core/disruption_budget.go @@ -9,13 +9,12 @@ import ( policyv1 "k8s.io/api/policy/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" ) -func CreateOrUpdateDisruptionBudget(ctx context.Context, req ctrl.Request, c client.Client, scheme *runtime.Scheme, p product.NamerAndOwnerProvider, owner client.Object, minAvailable, maxUnavailable *int) error { +// CreateOrUpdateDisruptionBudget creates or updates a PodDisruptionBudget from the given spec. +func CreateOrUpdateDisruptionBudget(ctx context.Context, c client.Client, scheme *runtime.Scheme, owner client.Object, pdbSpec *policyv1.PodDisruptionBudget) error { l := logr.FromContextOrDiscard(ctx) - pdbSpec := product.DefineDisruptionBudget(p, req, minAvailable, maxUnavailable) pdb := &policyv1.PodDisruptionBudget{ ObjectMeta: metav1.ObjectMeta{ diff --git a/internal/controller/core/package_manager.go b/internal/controller/core/package_manager.go index 1ab8626a..af2fef6a 100644 --- a/internal/controller/core/package_manager.go +++ b/internal/controller/core/package_manager.go @@ -686,9 +686,8 @@ func (r *PackageManagerReconciler) ensureDeployedService(ctx context.Context, re } // POD DISRUPTION BUDGET - if err := CreateOrUpdateDisruptionBudget( - ctx, req, r.Client, r.Scheme, pm, pm, ptr.To(product.DetermineMinAvailableReplicas(pm.Spec.Replicas)), nil, - ); err != nil { + pdb := product.DefineDisruptionBudget(pm, req, ptr.To(product.DetermineMinAvailableReplicas(pm.Spec.Replicas)), nil) + if err := CreateOrUpdateDisruptionBudget(ctx, r.Client, r.Scheme, pm, pdb); err != nil { return ctrl.Result{}, err } diff --git a/internal/controller/core/site_test.go b/internal/controller/core/site_test.go index f95095fb..6f5b77a8 100644 --- a/internal/controller/core/site_test.go +++ b/internal/controller/core/site_test.go @@ -14,6 +14,7 @@ import ( "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/traefikio/v1alpha1" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -343,6 +344,13 @@ func getMiddleware(t *testing.T, cli client.Client, siteNamespace, siteName stri return middleware } +func getPodDisruptionBudget(t *testing.T, cli client.Client, namespace, name string) *policyv1.PodDisruptionBudget { + pdb := &policyv1.PodDisruptionBudget{} + err := cli.Get(context.TODO(), client.ObjectKey{Name: name, Namespace: namespace}, pdb, &client.GetOptions{}) + assert.Nil(t, err) + return pdb +} + func TestSiteReconcileWithTolerations(t *testing.T) { siteName := "tolerations-site" siteNamespace := "posit-team" diff --git a/internal/controller/core/workbench.go b/internal/controller/core/workbench.go index b1ede828..721c9d5f 100644 --- a/internal/controller/core/workbench.go +++ b/internal/controller/core/workbench.go @@ -997,10 +997,15 @@ func (r *WorkbenchReconciler) ensureDeployedService(ctx context.Context, req ctr return ctrl.Result{}, err } - // POD DISRUPTION BUDGET - if err := CreateOrUpdateDisruptionBudget( - ctx, req, r.Client, r.Scheme, w, w, ptr.To(product.DetermineMinAvailableReplicas(w.Spec.Replicas)), nil, - ); err != nil { + // POD DISRUPTION BUDGET for server pods + serverPdb := product.DefineDisruptionBudget(w, req, ptr.To(product.DetermineMinAvailableReplicas(w.Spec.Replicas)), nil) + if err := CreateOrUpdateDisruptionBudget(ctx, r.Client, r.Scheme, w, serverPdb); err != nil { + return ctrl.Result{}, err + } + + // POD DISRUPTION BUDGET for session pods (prevents eviction during node drains) + sessionPdb := product.DefineSessionDisruptionBudget(w, req) + if err := CreateOrUpdateDisruptionBudget(ctx, r.Client, r.Scheme, w, sessionPdb); err != nil { return ctrl.Result{}, err } diff --git a/internal/controller/core/workbench_test.go b/internal/controller/core/workbench_test.go index 7589801c..ea494446 100644 --- a/internal/controller/core/workbench_test.go +++ b/internal/controller/core/workbench_test.go @@ -355,3 +355,37 @@ func TestWorkbenchLoadBalancingDisabled(t *testing.T) { assert.NotEqual(t, "load-balancer-config", v.Name, "Should not have load-balancer-config volume when load balancing is disabled") } } + +func TestWorkbenchPodDisruptionBudgets(t *testing.T) { + ctx := context.Background() + ns := "posit-team" + name := "workbench-pdb" + + ctx, r, req, cli := initWorkbenchReconciler(t, ctx, ns, name) + + wb := defineDefaultWorkbench(t, ns, name) + + err := internal.BasicCreateOrUpdate(ctx, r, r.GetLogger(ctx), req.NamespacedName, &positcov1beta1.Workbench{}, wb) + require.NoError(t, err) + + wb = getWorkbench(t, cli, ns, name) + + res, err := r.ReconcileWorkbench(ctx, req, wb) + require.NoError(t, err) + require.True(t, res.IsZero()) + + // Verify session PDB is created + sessionPdb := getPodDisruptionBudget(t, cli, ns, name+"-workbench-sessions") + require.NotNil(t, sessionPdb, "Session PDB should be created") + assert.Equal(t, name+"-workbench-sessions", sessionPdb.Name) + + // Verify session PDB has correct selector to target session pods + require.NotNil(t, sessionPdb.Spec.Selector, "Session PDB should have a selector") + assert.Equal(t, wb.ComponentName(), sessionPdb.Spec.Selector.MatchLabels["launcher-instance-id"], + "Session PDB should select pods with launcher-instance-id label matching workbench component name") + + // Verify session PDB has maxUnavailable=0 to prevent any evictions + require.NotNil(t, sessionPdb.Spec.MaxUnavailable, "Session PDB should have maxUnavailable set") + assert.Equal(t, int32(0), sessionPdb.Spec.MaxUnavailable.IntVal, + "Session PDB should have maxUnavailable=0 to prevent session evictions") +}