From 693201146a2725aea6c97eeaa6024f52dbf8e7b1 Mon Sep 17 00:00:00 2001 From: Anthony Hausman Date: Tue, 4 Aug 2026 19:06:01 +0200 Subject: [PATCH] feat(agent): add optional PodDisruptionBudget to Agent CRD New podDisruptionBudget field on SharedDeploymentSpec, so Declarative, BYO and SandboxAgent inherit it. Presence of the block enables the budget; removing it prunes the object. The policy API group is not covered by the core/apps/batch/gateway wildcards in the writer and getter roles, so both need an explicit poddisruptionbudgets grant or the controller cannot manage one. MutateFuncFor needs a dedicated case rather than the mergo fallback: minAvailable and maxUnavailable are mutually exclusive, and mergo cannot unset the field being switched away from, which leaves a spec the API server rejects for setting both. Assisted-by: Claude Opus 5 Signed-off-by: Anthony Hausman --- .../config/crd/bases/kagent.dev_agents.yaml | 76 ++++ .../crd/bases/kagent.dev_sandboxagents.yaml | 76 ++++ go/api/v1alpha2/agent_types.go | 34 ++ go/api/v1alpha2/zz_generated.deepcopy.go | 37 ++ .../internal/controller/agent_controller.go | 1 + .../controller/sandboxagent_controller.go | 1 + .../translator/agent/adk_api_translator.go | 5 + .../translator/agent/deployments.go | 7 + .../translator/agent/manifest_builder.go | 25 +- .../agent/pod_disruption_budget_test.go | 222 ++++++++++++ .../agent/testdata/inputs/agent_with_pdb.yaml | 38 ++ .../testdata/inputs/byo_agent_with_pdb.yaml | 18 + .../testdata/outputs/agent_with_pdb.json | 336 ++++++++++++++++++ .../testdata/outputs/byo_agent_with_pdb.json | 314 ++++++++++++++++ .../internal/controller/translator/mutate.go | 17 + .../controller/translator/mutate_test.go | 130 +++++++ go/core/test/e2e/invoke_api_test.go | 14 + go/core/test/e2e/poddisruptionbudget_test.go | 137 +++++++ .../templates/kagent.dev_agents.yaml | 76 ++++ .../templates/kagent.dev_sandboxagents.yaml | 76 ++++ helm/kagent/templates/rbac/getter-role.yaml | 10 + helm/kagent/templates/rbac/writer-role.yaml | 12 + 22 files changed, 1660 insertions(+), 2 deletions(-) create mode 100644 go/core/internal/controller/translator/agent/pod_disruption_budget_test.go create mode 100644 go/core/internal/controller/translator/agent/testdata/inputs/agent_with_pdb.yaml create mode 100644 go/core/internal/controller/translator/agent/testdata/inputs/byo_agent_with_pdb.yaml create mode 100644 go/core/internal/controller/translator/agent/testdata/outputs/agent_with_pdb.json create mode 100644 go/core/internal/controller/translator/agent/testdata/outputs/byo_agent_with_pdb.json create mode 100644 go/core/internal/controller/translator/mutate_test.go create mode 100644 go/core/test/e2e/poddisruptionbudget_test.go diff --git a/go/api/config/crd/bases/kagent.dev_agents.yaml b/go/api/config/crd/bases/kagent.dev_agents.yaml index f9d7eefcf..79416207d 100644 --- a/go/api/config/crd/bases/kagent.dev_agents.yaml +++ b/go/api/config/crd/bases/kagent.dev_agents.yaml @@ -5217,6 +5217,44 @@ spec: description: NodeSelector restricts the nodes the agent pods can be scheduled on. type: object + podDisruptionBudget: + description: |- + PodDisruptionBudget requests a PodDisruptionBudget for the agent pods, limiting how + many may be disrupted by voluntary evictions such as node drains. Omit the field to + create no budget; removing it later deletes the budget. This has no effect when the + agent runs with the Sandbox workload mode, as no Deployment is created in that mode. + properties: + maxUnavailable: + anyOf: + - type: integer + - type: string + description: |- + MaxUnavailable is the number or percentage of pods that may be unavailable. + Mutually exclusive with MinAvailable. + x-kubernetes-int-or-string: true + minAvailable: + anyOf: + - type: integer + - type: string + description: |- + MinAvailable is the number or percentage of pods that must remain available. + Mutually exclusive with MaxUnavailable. + x-kubernetes-int-or-string: true + unhealthyPodEvictionPolicy: + description: |- + UnhealthyPodEvictionPolicy defines when unhealthy pods should be considered for + eviction. AlwaysAllow permits evicting unhealthy pods even once the budget is + exhausted, which stops a crash-looping pod from wedging a node drain. + Defaults to the Kubernetes default of IfHealthyBudget. Requires Kubernetes >= 1.27. + enum: + - IfHealthyBudget + - AlwaysAllow + type: string + type: object + x-kubernetes-validations: + - message: exactly one of minAvailable or maxUnavailable must + be set + rule: has(self.minAvailable) != has(self.maxUnavailable) podSecurityContext: description: |- PodSecurityContext holds pod-level security attributes and common container settings. @@ -10619,6 +10657,44 @@ spec: description: NodeSelector restricts the nodes the agent pods can be scheduled on. type: object + podDisruptionBudget: + description: |- + PodDisruptionBudget requests a PodDisruptionBudget for the agent pods, limiting how + many may be disrupted by voluntary evictions such as node drains. Omit the field to + create no budget; removing it later deletes the budget. This has no effect when the + agent runs with the Sandbox workload mode, as no Deployment is created in that mode. + properties: + maxUnavailable: + anyOf: + - type: integer + - type: string + description: |- + MaxUnavailable is the number or percentage of pods that may be unavailable. + Mutually exclusive with MinAvailable. + x-kubernetes-int-or-string: true + minAvailable: + anyOf: + - type: integer + - type: string + description: |- + MinAvailable is the number or percentage of pods that must remain available. + Mutually exclusive with MaxUnavailable. + x-kubernetes-int-or-string: true + unhealthyPodEvictionPolicy: + description: |- + UnhealthyPodEvictionPolicy defines when unhealthy pods should be considered for + eviction. AlwaysAllow permits evicting unhealthy pods even once the budget is + exhausted, which stops a crash-looping pod from wedging a node drain. + Defaults to the Kubernetes default of IfHealthyBudget. Requires Kubernetes >= 1.27. + enum: + - IfHealthyBudget + - AlwaysAllow + type: string + type: object + x-kubernetes-validations: + - message: exactly one of minAvailable or maxUnavailable must + be set + rule: has(self.minAvailable) != has(self.maxUnavailable) podSecurityContext: description: |- PodSecurityContext holds pod-level security attributes and common container settings. diff --git a/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml b/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml index caf39a509..df5989aa9 100644 --- a/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml +++ b/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml @@ -2874,6 +2874,44 @@ spec: description: NodeSelector restricts the nodes the agent pods can be scheduled on. type: object + podDisruptionBudget: + description: |- + PodDisruptionBudget requests a PodDisruptionBudget for the agent pods, limiting how + many may be disrupted by voluntary evictions such as node drains. Omit the field to + create no budget; removing it later deletes the budget. This has no effect when the + agent runs with the Sandbox workload mode, as no Deployment is created in that mode. + properties: + maxUnavailable: + anyOf: + - type: integer + - type: string + description: |- + MaxUnavailable is the number or percentage of pods that may be unavailable. + Mutually exclusive with MinAvailable. + x-kubernetes-int-or-string: true + minAvailable: + anyOf: + - type: integer + - type: string + description: |- + MinAvailable is the number or percentage of pods that must remain available. + Mutually exclusive with MaxUnavailable. + x-kubernetes-int-or-string: true + unhealthyPodEvictionPolicy: + description: |- + UnhealthyPodEvictionPolicy defines when unhealthy pods should be considered for + eviction. AlwaysAllow permits evicting unhealthy pods even once the budget is + exhausted, which stops a crash-looping pod from wedging a node drain. + Defaults to the Kubernetes default of IfHealthyBudget. Requires Kubernetes >= 1.27. + enum: + - IfHealthyBudget + - AlwaysAllow + type: string + type: object + x-kubernetes-validations: + - message: exactly one of minAvailable or maxUnavailable must + be set + rule: has(self.minAvailable) != has(self.maxUnavailable) podSecurityContext: description: |- PodSecurityContext holds pod-level security attributes and common container settings. @@ -8276,6 +8314,44 @@ spec: description: NodeSelector restricts the nodes the agent pods can be scheduled on. type: object + podDisruptionBudget: + description: |- + PodDisruptionBudget requests a PodDisruptionBudget for the agent pods, limiting how + many may be disrupted by voluntary evictions such as node drains. Omit the field to + create no budget; removing it later deletes the budget. This has no effect when the + agent runs with the Sandbox workload mode, as no Deployment is created in that mode. + properties: + maxUnavailable: + anyOf: + - type: integer + - type: string + description: |- + MaxUnavailable is the number or percentage of pods that may be unavailable. + Mutually exclusive with MinAvailable. + x-kubernetes-int-or-string: true + minAvailable: + anyOf: + - type: integer + - type: string + description: |- + MinAvailable is the number or percentage of pods that must remain available. + Mutually exclusive with MaxUnavailable. + x-kubernetes-int-or-string: true + unhealthyPodEvictionPolicy: + description: |- + UnhealthyPodEvictionPolicy defines when unhealthy pods should be considered for + eviction. AlwaysAllow permits evicting unhealthy pods even once the budget is + exhausted, which stops a crash-looping pod from wedging a node drain. + Defaults to the Kubernetes default of IfHealthyBudget. Requires Kubernetes >= 1.27. + enum: + - IfHealthyBudget + - AlwaysAllow + type: string + type: object + x-kubernetes-validations: + - message: exactly one of minAvailable or maxUnavailable must + be set + rule: has(self.minAvailable) != has(self.maxUnavailable) podSecurityContext: description: |- PodSecurityContext holds pod-level security attributes and common container settings. diff --git a/go/api/v1alpha2/agent_types.go b/go/api/v1alpha2/agent_types.go index 317f75e1e..76663505a 100644 --- a/go/api/v1alpha2/agent_types.go +++ b/go/api/v1alpha2/agent_types.go @@ -21,10 +21,12 @@ import ( "fmt" corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -495,6 +497,38 @@ type SharedDeploymentSpec struct { // Useful for sidecars such as token proxies, log shippers, or security agents. // +optional ExtraContainers []corev1.Container `json:"extraContainers,omitempty"` + // PodDisruptionBudget requests a PodDisruptionBudget for the agent pods, limiting how + // many may be disrupted by voluntary evictions such as node drains. Omit the field to + // create no budget; removing it later deletes the budget. This has no effect when the + // agent runs with the Sandbox workload mode, as no Deployment is created in that mode. + // +optional + PodDisruptionBudget *PodDisruptionBudgetSpec `json:"podDisruptionBudget,omitempty"` +} + +// PodDisruptionBudgetSpec configures the PodDisruptionBudget created for an agent's pods. +// The selector is always derived from the agent, so only the disruption thresholds are +// configurable here. +// +// Beware that a budget the Deployment can never satisfy blocks voluntary evictions +// entirely, which makes node drains and cluster upgrades hang. In particular, avoid +// setting minAvailable to the replica count. +// +kubebuilder:validation:XValidation:message="exactly one of minAvailable or maxUnavailable must be set",rule="has(self.minAvailable) != has(self.maxUnavailable)" +type PodDisruptionBudgetSpec struct { + // MinAvailable is the number or percentage of pods that must remain available. + // Mutually exclusive with MaxUnavailable. + // +optional + MinAvailable *intstr.IntOrString `json:"minAvailable,omitempty"` + // MaxUnavailable is the number or percentage of pods that may be unavailable. + // Mutually exclusive with MinAvailable. + // +optional + MaxUnavailable *intstr.IntOrString `json:"maxUnavailable,omitempty"` + // UnhealthyPodEvictionPolicy defines when unhealthy pods should be considered for + // eviction. AlwaysAllow permits evicting unhealthy pods even once the budget is + // exhausted, which stops a crash-looping pod from wedging a node drain. + // Defaults to the Kubernetes default of IfHealthyBudget. Requires Kubernetes >= 1.27. + // +optional + // +kubebuilder:validation:Enum=IfHealthyBudget;AlwaysAllow + UnhealthyPodEvictionPolicy *policyv1.UnhealthyPodEvictionPolicyType `json:"unhealthyPodEvictionPolicy,omitempty"` } type ServiceAccountConfig struct { diff --git a/go/api/v1alpha2/zz_generated.deepcopy.go b/go/api/v1alpha2/zz_generated.deepcopy.go index 46813cc71..56fe0ddb8 100644 --- a/go/api/v1alpha2/zz_generated.deepcopy.go +++ b/go/api/v1alpha2/zz_generated.deepcopy.go @@ -22,9 +22,11 @@ package v1alpha2 import ( "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. @@ -1424,6 +1426,36 @@ func (in *OpenAIConfig) DeepCopy() *OpenAIConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PodDisruptionBudgetSpec) DeepCopyInto(out *PodDisruptionBudgetSpec) { + *out = *in + if in.MinAvailable != nil { + in, out := &in.MinAvailable, &out.MinAvailable + *out = new(intstr.IntOrString) + **out = **in + } + if in.MaxUnavailable != nil { + in, out := &in.MaxUnavailable, &out.MaxUnavailable + *out = new(intstr.IntOrString) + **out = **in + } + if in.UnhealthyPodEvictionPolicy != nil { + in, out := &in.UnhealthyPodEvictionPolicy, &out.UnhealthyPodEvictionPolicy + *out = new(policyv1.UnhealthyPodEvictionPolicyType) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PodDisruptionBudgetSpec. +func (in *PodDisruptionBudgetSpec) DeepCopy() *PodDisruptionBudgetSpec { + if in == nil { + return nil + } + out := new(PodDisruptionBudgetSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PromptSource) DeepCopyInto(out *PromptSource) { *out = *in @@ -1896,6 +1928,11 @@ func (in *SharedDeploymentSpec) DeepCopyInto(out *SharedDeploymentSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.PodDisruptionBudget != nil { + in, out := &in.PodDisruptionBudget, &out.PodDisruptionBudget + *out = new(PodDisruptionBudgetSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SharedDeploymentSpec. diff --git a/go/core/internal/controller/agent_controller.go b/go/core/internal/controller/agent_controller.go index e13a03d5c..db585cea4 100644 --- a/go/core/internal/controller/agent_controller.go +++ b/go/core/internal/controller/agent_controller.go @@ -53,6 +53,7 @@ type AgentController struct { // +kubebuilder:rbac:groups=core,resources=serviceaccounts,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=policy,resources=poddisruptionbudgets,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=agents.x-k8s.io,resources=sandboxes,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=agents.x-k8s.io,resources=sandboxes/status,verbs=get;update;patch // +kubebuilder:rbac:groups=agents.x-k8s.io,resources=sandboxes/finalizers,verbs=update diff --git a/go/core/internal/controller/sandboxagent_controller.go b/go/core/internal/controller/sandboxagent_controller.go index fd8330aee..20e9bbdaa 100644 --- a/go/core/internal/controller/sandboxagent_controller.go +++ b/go/core/internal/controller/sandboxagent_controller.go @@ -62,6 +62,7 @@ type SandboxAgentController struct { // +kubebuilder:rbac:groups=core,resources=serviceaccounts,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=policy,resources=poddisruptionbudgets,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=agents.x-k8s.io,resources=sandboxes,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=agents.x-k8s.io,resources=sandboxes/status,verbs=get;update;patch // +kubebuilder:rbac:groups=agents.x-k8s.io,resources=sandboxes/finalizers,verbs=update diff --git a/go/core/internal/controller/translator/agent/adk_api_translator.go b/go/core/internal/controller/translator/agent/adk_api_translator.go index ea2ff6eba..c0cff9443 100644 --- a/go/core/internal/controller/translator/agent/adk_api_translator.go +++ b/go/core/internal/controller/translator/agent/adk_api_translator.go @@ -30,6 +30,7 @@ import ( "github.com/kagent-dev/kmcp/api/v1alpha1" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -255,6 +256,10 @@ func (r *adkApiTranslator) GetOwnedResourceTypes() []client.Object { &corev1.Secret{}, &corev1.Service{}, &corev1.ServiceAccount{}, + // Optional per agent, but must be listed unconditionally: this set also drives + // the prune pass, so omitting it would orphan the budget of an agent that stops + // requesting one. + &policyv1.PodDisruptionBudget{}, } for _, plugin := range r.plugins { diff --git a/go/core/internal/controller/translator/agent/deployments.go b/go/core/internal/controller/translator/agent/deployments.go index 39355b786..d3b4eb904 100644 --- a/go/core/internal/controller/translator/agent/deployments.go +++ b/go/core/internal/controller/translator/agent/deployments.go @@ -49,6 +49,7 @@ type resolvedDeployment struct { ServiceAccountName *string ServiceAccountConfig *v1alpha2.ServiceAccountConfig ExtraContainers []corev1.Container + PodDisruptionBudget *v1alpha2.PodDisruptionBudgetSpec } // getDefaultResources sets default resource requirements if not specified @@ -267,6 +268,9 @@ func resolveInlineDeployment(agent v1alpha2.AgentObject, mdd *modelDeploymentDat ServiceAccountName: spec.ServiceAccountName, ServiceAccountConfig: spec.ServiceAccountConfig, ExtraContainers: slices.Clone(spec.ExtraContainers), + // Left nil when unset: a nil budget means "create no PodDisruptionBudget", + // which is what lets the reconciler prune one that was previously requested. + PodDisruptionBudget: spec.PodDisruptionBudget.DeepCopy(), } // Precedence: agent-level serviceAccountName > global default > auto-created SA (agent name) @@ -353,6 +357,9 @@ func resolveByoDeployment(agent v1alpha2.AgentObject) (*resolvedDeployment, erro ServiceAccountName: spec.ServiceAccountName, ServiceAccountConfig: spec.ServiceAccountConfig, ExtraContainers: slices.Clone(spec.ExtraContainers), + // Left nil when unset: a nil budget means "create no PodDisruptionBudget", + // which is what lets the reconciler prune one that was previously requested. + PodDisruptionBudget: spec.PodDisruptionBudget.DeepCopy(), } // Precedence: agent-level serviceAccountName > global default > auto-created SA (agent name) diff --git a/go/core/internal/controller/translator/agent/manifest_builder.go b/go/core/internal/controller/translator/agent/manifest_builder.go index 31d60a8d6..87635c769 100644 --- a/go/core/internal/controller/translator/agent/manifest_builder.go +++ b/go/core/internal/controller/translator/agent/manifest_builder.go @@ -19,6 +19,7 @@ import ( "github.com/kagent-dev/kagent/go/core/pkg/sandboxbackend" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" "sigs.k8s.io/controller-runtime/pkg/client" @@ -616,7 +617,7 @@ func (a *adkApiTranslator) buildWorkloadObjects( svcPort.AppProtocol = &proto } - return []client.Object{ + objs := []client.Object{ &appsv1.Deployment{ TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"}, ObjectMeta: manifestCtx.deploymentObjectMeta(), @@ -642,7 +643,27 @@ func (a *adkApiTranslator) buildWorkloadObjects( Type: corev1.ServiceTypeClusterIP, }, }, - }, nil + } + + // Only emitted when the agent asks for one. A nil spec leaves the object out of + // the manifest, so any budget from a previous generation is pruned by the + // reconciler rather than orphaned. + if pdb := manifestCtx.deployment.PodDisruptionBudget; pdb != nil { + objs = append(objs, &policyv1.PodDisruptionBudget{ + TypeMeta: metav1.TypeMeta{APIVersion: "policy/v1", Kind: "PodDisruptionBudget"}, + ObjectMeta: manifestCtx.objectMeta(), + Spec: policyv1.PodDisruptionBudgetSpec{ + // Same selector labels the Deployment matches on, so the budget cannot + // drift from the pods it is meant to protect. + Selector: &metav1.LabelSelector{MatchLabels: manifestCtx.selectorLabels}, + MinAvailable: pdb.MinAvailable, + MaxUnavailable: pdb.MaxUnavailable, + UnhealthyPodEvictionPolicy: pdb.UnhealthyPodEvictionPolicy, + }, + }) + } + + return objs, nil } func (a *adkApiTranslator) setManifestOwnerReferences( diff --git a/go/core/internal/controller/translator/agent/pod_disruption_budget_test.go b/go/core/internal/controller/translator/agent/pod_disruption_budget_test.go new file mode 100644 index 000000000..cb1f0210c --- /dev/null +++ b/go/core/internal/controller/translator/agent/pod_disruption_budget_test.go @@ -0,0 +1,222 @@ +package agent_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + translator "github.com/kagent-dev/kagent/go/core/internal/controller/translator/agent" + policyv1 "k8s.io/api/policy/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + schemev1 "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// translateBYOAgentWithDeployment is the BYO counterpart of translateAgentWithDeployment +// (deployment_annotations_test.go). BYO agents resolve their deployment through a separate +// function from declarative ones, so shared fields need coverage on both paths. +func translateBYOAgentWithDeployment( + t *testing.T, + deploymentSpec v1alpha2.SharedDeploymentSpec, +) *translator.AgentOutputs { + t.Helper() + + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "byo-agent", Namespace: "test"}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_BYO, + BYO: &v1alpha2.BYOAgentSpec{ + Deployment: &v1alpha2.ByoDeploymentSpec{ + Image: "example.com/byo:latest", + SharedDeploymentSpec: deploymentSpec, + }, + }, + }, + } + + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(agent).Build() + trans := translator.NewAdkApiTranslator( + kubeClient, types.NamespacedName{Namespace: "test", Name: "test-model"}, nil, "", nil, + ) + + result, err := translator.TranslateAgent(context.Background(), trans, agent) + require.NoError(t, err) + require.NotNil(t, result) + + return result +} + +// findPodDisruptionBudget returns the PodDisruptionBudget in the manifest, or nil when the +// translator emitted none. +func findPodDisruptionBudget(manifest []client.Object) *policyv1.PodDisruptionBudget { + for _, obj := range manifest { + if pdb, ok := obj.(*policyv1.PodDisruptionBudget); ok { + return pdb + } + } + return nil +} + +// TestPodDisruptionBudget_OmittedWhenUnset asserts the no-op path: an agent that does not +// ask for a budget gets no PodDisruptionBudget in its manifest. This is what allows the +// reconciler to prune a budget an agent previously requested. +func TestPodDisruptionBudget_OmittedWhenUnset(t *testing.T) { + result := translateAgentWithDeployment(t, nil, v1alpha2.SharedDeploymentSpec{}) + + assert.Nil(t, findPodDisruptionBudget(result.Manifest), + "no PodDisruptionBudget should be emitted when podDisruptionBudget is unset") +} + +func TestPodDisruptionBudget_MaxUnavailable(t *testing.T) { + result := translateAgentWithDeployment(t, nil, v1alpha2.SharedDeploymentSpec{ + PodDisruptionBudget: &v1alpha2.PodDisruptionBudgetSpec{ + MaxUnavailable: new(intstr.FromInt32(1)), + }, + }) + + pdb := findPodDisruptionBudget(result.Manifest) + require.NotNil(t, pdb, "PodDisruptionBudget should be in manifest") + + assert.Equal(t, "policy/v1", pdb.APIVersion) + assert.Equal(t, "PodDisruptionBudget", pdb.Kind) + assert.Equal(t, "test-agent", pdb.Name) + assert.Equal(t, "test", pdb.Namespace) + + require.NotNil(t, pdb.Spec.MaxUnavailable) + assert.Equal(t, intstr.FromInt32(1), *pdb.Spec.MaxUnavailable) + assert.Nil(t, pdb.Spec.MinAvailable) + assert.Nil(t, pdb.Spec.UnhealthyPodEvictionPolicy) +} + +func TestPodDisruptionBudget_MinAvailablePercentage(t *testing.T) { + result := translateAgentWithDeployment(t, nil, v1alpha2.SharedDeploymentSpec{ + PodDisruptionBudget: &v1alpha2.PodDisruptionBudgetSpec{ + MinAvailable: new(intstr.FromString("50%")), + }, + }) + + pdb := findPodDisruptionBudget(result.Manifest) + require.NotNil(t, pdb) + + require.NotNil(t, pdb.Spec.MinAvailable) + assert.Equal(t, intstr.FromString("50%"), *pdb.Spec.MinAvailable) + assert.Nil(t, pdb.Spec.MaxUnavailable) +} + +func TestPodDisruptionBudget_UnhealthyPodEvictionPolicy(t *testing.T) { + policy := policyv1.AlwaysAllow + result := translateAgentWithDeployment(t, nil, v1alpha2.SharedDeploymentSpec{ + PodDisruptionBudget: &v1alpha2.PodDisruptionBudgetSpec{ + MaxUnavailable: new(intstr.FromInt32(1)), + UnhealthyPodEvictionPolicy: &policy, + }, + }) + + pdb := findPodDisruptionBudget(result.Manifest) + require.NotNil(t, pdb) + + require.NotNil(t, pdb.Spec.UnhealthyPodEvictionPolicy) + assert.Equal(t, policyv1.AlwaysAllow, *pdb.Spec.UnhealthyPodEvictionPolicy) +} + +// TestPodDisruptionBudget_SelectorMatchesDeployment is the assertion that matters most: a +// budget whose selector does not match the Deployment selector silently protects nothing. +func TestPodDisruptionBudget_SelectorMatchesDeployment(t *testing.T) { + result := translateAgentWithDeployment(t, nil, v1alpha2.SharedDeploymentSpec{ + PodDisruptionBudget: &v1alpha2.PodDisruptionBudgetSpec{ + MaxUnavailable: new(intstr.FromInt32(1)), + }, + }) + + deployment := findDeployment(t, result) + pdb := findPodDisruptionBudget(result.Manifest) + require.NotNil(t, pdb) + + require.NotNil(t, deployment.Spec.Selector) + require.NotNil(t, pdb.Spec.Selector) + assert.Equal(t, deployment.Spec.Selector.MatchLabels, pdb.Spec.Selector.MatchLabels, + "PodDisruptionBudget selector must match the Deployment selector exactly") + assert.NotEmpty(t, pdb.Spec.Selector.MatchLabels, "selector must not be empty") + + // The pod template must actually satisfy the selector, otherwise the budget matches + // zero pods and permits unlimited disruption. + for key, want := range pdb.Spec.Selector.MatchLabels { + assert.Equal(t, want, deployment.Spec.Template.Labels[key], + "pod template label %q should satisfy the budget selector", key) + } +} + +// TestPodDisruptionBudget_BYOAgent asserts the field works through the BYO resolve path, +// which is a separate function from the declarative one. +func TestPodDisruptionBudget_BYOAgent(t *testing.T) { + result := translateBYOAgentWithDeployment(t, v1alpha2.SharedDeploymentSpec{ + PodDisruptionBudget: &v1alpha2.PodDisruptionBudgetSpec{ + MinAvailable: new(intstr.FromInt32(2)), + }, + }) + + pdb := findPodDisruptionBudget(result.Manifest) + require.NotNil(t, pdb, "BYO agents should also get a PodDisruptionBudget") + + require.NotNil(t, pdb.Spec.MinAvailable) + assert.Equal(t, intstr.FromInt32(2), *pdb.Spec.MinAvailable) + + deployment := findDeployment(t, result) + assert.Equal(t, deployment.Spec.Selector.MatchLabels, pdb.Spec.Selector.MatchLabels) +} + +func TestPodDisruptionBudget_OmittedWhenUnsetForBYOAgent(t *testing.T) { + result := translateBYOAgentWithDeployment(t, v1alpha2.SharedDeploymentSpec{}) + + assert.Nil(t, findPodDisruptionBudget(result.Manifest)) +} + +// TestPodDisruptionBudget_RegisteredAsOwnedType asserts the type is in the owned-resource +// set. That set drives the prune pass, so a missing entry leaves a stale budget behind +// forever once an agent stops requesting one. +func TestPodDisruptionBudget_RegisteredAsOwnedType(t *testing.T) { + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + kubeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + trans := translator.NewAdkApiTranslator( + kubeClient, types.NamespacedName{Namespace: "test", Name: "test-model"}, nil, "", nil, + ) + + var found bool + for _, obj := range trans.GetOwnedResourceTypes() { + if _, ok := obj.(*policyv1.PodDisruptionBudget); ok { + found = true + break + } + } + assert.True(t, found, + "PodDisruptionBudget must be in GetOwnedResourceTypes so stale budgets are pruned") +} + +// TestPodDisruptionBudget_DoesNotMutateAgentSpec asserts the resolved deployment holds a +// copy, so translation never writes back into the agent object held by the client cache. +func TestPodDisruptionBudget_DoesNotMutateAgentSpec(t *testing.T) { + spec := &v1alpha2.PodDisruptionBudgetSpec{ + MaxUnavailable: new(intstr.FromInt32(1)), + } + + result := translateAgentWithDeployment(t, nil, v1alpha2.SharedDeploymentSpec{ + PodDisruptionBudget: spec, + }) + + pdb := findPodDisruptionBudget(result.Manifest) + require.NotNil(t, pdb) + + // Mutating the emitted budget must not reach back into the spec the agent owns. + pdb.Spec.MaxUnavailable = new(intstr.FromInt32(99)) + assert.Equal(t, intstr.FromInt32(1), *spec.MaxUnavailable, + "translation must not share the IntOrString pointer with the agent spec") +} diff --git a/go/core/internal/controller/translator/agent/testdata/inputs/agent_with_pdb.yaml b/go/core/internal/controller/translator/agent/testdata/inputs/agent_with_pdb.yaml new file mode 100644 index 000000000..a509fba0b --- /dev/null +++ b/go/core/internal/controller/translator/agent/testdata/inputs/agent_with_pdb.yaml @@ -0,0 +1,38 @@ +operation: translateAgent +targetObject: agent-with-pdb +namespace: test +objects: + - apiVersion: v1 + kind: Secret + metadata: + name: openai-secret + namespace: test + data: + api-key: c2stdGVzdC1hcGkta2V5 # base64 encoded "sk-test-api-key" + - apiVersion: kagent.dev/v1alpha2 + kind: ModelConfig + metadata: + name: basic-model + namespace: test + spec: + provider: OpenAI + model: gpt-4o + apiKeySecret: openai-secret + apiKeySecretKey: api-key + - apiVersion: kagent.dev/v1alpha2 + kind: Agent + metadata: + name: agent-with-pdb + namespace: test + spec: + type: Declarative + declarative: + description: A declarative agent guarded by a PodDisruptionBudget. + systemMessage: You are a helpful assistant. + modelConfig: basic-model + deployment: + replicas: 3 + podDisruptionBudget: + maxUnavailable: 1 + unhealthyPodEvictionPolicy: AlwaysAllow + tools: [] diff --git a/go/core/internal/controller/translator/agent/testdata/inputs/byo_agent_with_pdb.yaml b/go/core/internal/controller/translator/agent/testdata/inputs/byo_agent_with_pdb.yaml new file mode 100644 index 000000000..4f320c960 --- /dev/null +++ b/go/core/internal/controller/translator/agent/testdata/inputs/byo_agent_with_pdb.yaml @@ -0,0 +1,18 @@ +operation: translateAgent +targetObject: byo-agent-with-pdb +namespace: test +objects: + - apiVersion: kagent.dev/v1alpha2 + kind: Agent + metadata: + name: byo-agent-with-pdb + namespace: test + spec: + type: BYO + byo: + deployment: + image: example.com/byo-agent:v1 + replicas: 4 + # Percentage thresholds exercise the string arm of IntOrString. + podDisruptionBudget: + minAvailable: 50% diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_pdb.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_pdb.json new file mode 100644 index 000000000..c6f689cdc --- /dev/null +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_pdb.json @@ -0,0 +1,336 @@ +{ + "agentCard": { + "capabilities": { + "streaming": true + }, + "defaultInputModes": [ + "text" + ], + "defaultOutputModes": [ + "text" + ], + "description": "", + "name": "agent_with_pdb", + "skills": null, + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-with-pdb.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-with-pdb.test:8080" + } + ], + "version": "" + }, + "config": { + "description": "", + "instruction": "You are a helpful assistant.", + "model": { + "base_url": "", + "model": "gpt-4o", + "type": "openai" + }, + "stream": false + }, + "manifest": [ + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-pdb", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-pdb" + }, + "name": "agent-with-pdb", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-pdb", + "uid": "" + } + ] + }, + "stringData": { + "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent_with_pdb\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-with-pdb.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-with-pdb.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-with-pdb.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}" + } + }, + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-pdb", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-pdb" + }, + "name": "agent-with-pdb", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-pdb", + "uid": "" + } + ] + } + }, + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-pdb", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-pdb" + }, + "name": "agent-with-pdb", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-pdb", + "uid": "" + } + ] + }, + "spec": { + "replicas": 3, + "selector": { + "matchLabels": { + "app": "kagent", + "kagent": "agent-with-pdb" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": 1, + "maxUnavailable": 0 + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "annotations": { + "kagent.dev/config-hash": "12718663340582788990" + }, + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-pdb", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-pdb" + } + }, + "spec": { + "containers": [ + { + "args": [ + "--host", + "0.0.0.0", + "--port", + "8080", + "--filepath", + "/config" + ], + "env": [ + { + "name": "OPENAI_API_KEY", + "valueFrom": { + "secretKeyRef": { + "key": "api-key", + "name": "openai-secret" + } + } + }, + { + "name": "KAGENT_NAMESPACE", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.namespace" + } + } + }, + { + "name": "KAGENT_NAME", + "value": "agent-with-pdb" + }, + { + "name": "KAGENT_URL", + "value": "http://kagent-controller.kagent:8083" + } + ], + "image": "ghcr.io/kagent-dev/kagent/app:dev", + "imagePullPolicy": "IfNotPresent", + "name": "kagent", + "ports": [ + { + "containerPort": 8080, + "name": "http" + } + ], + "readinessProbe": { + "httpGet": { + "path": "/.well-known/agent-card.json", + "port": "http" + }, + "initialDelaySeconds": 15, + "periodSeconds": 15, + "timeoutSeconds": 15 + }, + "resources": { + "limits": { + "cpu": "2", + "memory": "1Gi" + }, + "requests": { + "cpu": "100m", + "memory": "384Mi" + } + }, + "volumeMounts": [ + { + "mountPath": "/config", + "name": "config" + }, + { + "mountPath": "/var/run/secrets/tokens", + "name": "kagent-token" + } + ] + } + ], + "serviceAccountName": "agent-with-pdb", + "volumes": [ + { + "name": "config", + "secret": { + "secretName": "agent-with-pdb" + } + }, + { + "name": "kagent-token", + "projected": { + "sources": [ + { + "serviceAccountToken": { + "audience": "kagent", + "expirationSeconds": 3600, + "path": "kagent-token" + } + } + ] + } + } + ] + } + } + }, + "status": {} + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-pdb", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-pdb" + }, + "name": "agent-with-pdb", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-pdb", + "uid": "" + } + ] + }, + "spec": { + "ports": [ + { + "name": "http", + "port": 8080, + "targetPort": 8080 + } + ], + "selector": { + "app": "kagent", + "kagent": "agent-with-pdb" + }, + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } + }, + { + "apiVersion": "policy/v1", + "kind": "PodDisruptionBudget", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-pdb", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-pdb" + }, + "name": "agent-with-pdb", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-pdb", + "uid": "" + } + ] + }, + "spec": { + "maxUnavailable": 1, + "selector": { + "matchLabels": { + "app": "kagent", + "kagent": "agent-with-pdb" + } + }, + "unhealthyPodEvictionPolicy": "AlwaysAllow" + }, + "status": { + "currentHealthy": 0, + "desiredHealthy": 0, + "disruptionsAllowed": 0, + "expectedPods": 0 + } + } + ] +} \ No newline at end of file diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/byo_agent_with_pdb.json b/go/core/internal/controller/translator/agent/testdata/outputs/byo_agent_with_pdb.json new file mode 100644 index 000000000..5ba337d90 --- /dev/null +++ b/go/core/internal/controller/translator/agent/testdata/outputs/byo_agent_with_pdb.json @@ -0,0 +1,314 @@ +{ + "agentCard": { + "capabilities": { + "streaming": true + }, + "defaultInputModes": [ + "text" + ], + "defaultOutputModes": [ + "text" + ], + "description": "", + "name": "byo_agent_with_pdb", + "skills": null, + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://byo-agent-with-pdb.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://byo-agent-with-pdb.test:8080" + } + ], + "version": "" + }, + "config": { + "description": "", + "instruction": "", + "model": null + }, + "manifest": [ + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "byo-agent-with-pdb", + "app.kubernetes.io/part-of": "kagent", + "kagent": "byo-agent-with-pdb" + }, + "name": "byo-agent-with-pdb", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "byo-agent-with-pdb", + "uid": "" + } + ] + }, + "stringData": { + "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"byo_agent_with_pdb\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://byo-agent-with-pdb.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://byo-agent-with-pdb.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://byo-agent-with-pdb.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "config.json": "{\"model\":null,\"description\":\"\",\"instruction\":\"\"}" + } + }, + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "byo-agent-with-pdb", + "app.kubernetes.io/part-of": "kagent", + "kagent": "byo-agent-with-pdb" + }, + "name": "byo-agent-with-pdb", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "byo-agent-with-pdb", + "uid": "" + } + ] + } + }, + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "byo-agent-with-pdb", + "app.kubernetes.io/part-of": "kagent", + "kagent": "byo-agent-with-pdb" + }, + "name": "byo-agent-with-pdb", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "byo-agent-with-pdb", + "uid": "" + } + ] + }, + "spec": { + "replicas": 4, + "selector": { + "matchLabels": { + "app": "kagent", + "kagent": "byo-agent-with-pdb" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": 1, + "maxUnavailable": 0 + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "annotations": { + "kagent.dev/config-hash": "16669720524570664532" + }, + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "byo-agent-with-pdb", + "app.kubernetes.io/part-of": "kagent", + "kagent": "byo-agent-with-pdb" + } + }, + "spec": { + "containers": [ + { + "env": [ + { + "name": "KAGENT_NAMESPACE", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.namespace" + } + } + }, + { + "name": "KAGENT_NAME", + "value": "byo-agent-with-pdb" + }, + { + "name": "KAGENT_URL", + "value": "http://kagent-controller.kagent:8083" + } + ], + "image": "example.com/byo-agent:v1", + "imagePullPolicy": "IfNotPresent", + "name": "kagent", + "ports": [ + { + "containerPort": 8080, + "name": "http" + } + ], + "readinessProbe": { + "httpGet": { + "path": "/.well-known/agent-card.json", + "port": "http" + }, + "initialDelaySeconds": 15, + "periodSeconds": 15, + "timeoutSeconds": 15 + }, + "resources": { + "limits": { + "cpu": "2", + "memory": "1Gi" + }, + "requests": { + "cpu": "100m", + "memory": "384Mi" + } + }, + "volumeMounts": [ + { + "mountPath": "/config", + "name": "config" + }, + { + "mountPath": "/var/run/secrets/tokens", + "name": "kagent-token" + } + ] + } + ], + "serviceAccountName": "byo-agent-with-pdb", + "volumes": [ + { + "name": "config", + "secret": { + "secretName": "byo-agent-with-pdb" + } + }, + { + "name": "kagent-token", + "projected": { + "sources": [ + { + "serviceAccountToken": { + "audience": "kagent", + "expirationSeconds": 3600, + "path": "kagent-token" + } + } + ] + } + } + ] + } + } + }, + "status": {} + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "byo-agent-with-pdb", + "app.kubernetes.io/part-of": "kagent", + "kagent": "byo-agent-with-pdb" + }, + "name": "byo-agent-with-pdb", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "byo-agent-with-pdb", + "uid": "" + } + ] + }, + "spec": { + "ports": [ + { + "appProtocol": "kgateway.dev/a2a", + "name": "http", + "port": 8080, + "targetPort": 8080 + } + ], + "selector": { + "app": "kagent", + "kagent": "byo-agent-with-pdb" + }, + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } + }, + { + "apiVersion": "policy/v1", + "kind": "PodDisruptionBudget", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "byo-agent-with-pdb", + "app.kubernetes.io/part-of": "kagent", + "kagent": "byo-agent-with-pdb" + }, + "name": "byo-agent-with-pdb", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "byo-agent-with-pdb", + "uid": "" + } + ] + }, + "spec": { + "minAvailable": "50%", + "selector": { + "matchLabels": { + "app": "kagent", + "kagent": "byo-agent-with-pdb" + } + } + }, + "status": { + "currentHealthy": 0, + "desiredHealthy": 0, + "disruptionsAllowed": 0, + "expectedPods": 0 + } + } + ] +} \ No newline at end of file diff --git a/go/core/internal/controller/translator/mutate.go b/go/core/internal/controller/translator/mutate.go index 252bf0cc6..f6ca11b2b 100644 --- a/go/core/internal/controller/translator/mutate.go +++ b/go/core/internal/controller/translator/mutate.go @@ -6,6 +6,7 @@ import ( "dario.cat/mergo" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -59,6 +60,11 @@ func MutateFuncFor(existing, desired client.Object) controllerutil.MutateFn { wantDpl := desired.(*appsv1.Deployment) return mutateDeployment(dpl, wantDpl) + case *policyv1.PodDisruptionBudget: + pdb := existing.(*policyv1.PodDisruptionBudget) + wantPdb := desired.(*policyv1.PodDisruptionBudget) + mutatePodDisruptionBudget(pdb, wantPdb) + default: return mergeWithOverride(existing, desired) } @@ -89,6 +95,17 @@ func mutateService(existing, desired *corev1.Service) { existing.Spec.Selector = desired.Spec.Selector } +// mutatePodDisruptionBudget replaces the spec wholesale rather than merging it. +// +// minAvailable and maxUnavailable are mutually exclusive, so switching an agent from +// one to the other has to clear the field it is moving away from. The default +// mergeWithOverride path cannot do that: mergo only overwrites keys present in the +// desired object and leaves the stale field populated, producing a spec the API server +// rejects for setting both. +func mutatePodDisruptionBudget(existing, desired *policyv1.PodDisruptionBudget) { + existing.Spec = desired.Spec +} + func mutateDeployment(existing, desired *appsv1.Deployment) error { existing.Spec.MinReadySeconds = desired.Spec.MinReadySeconds existing.Spec.Paused = desired.Spec.Paused diff --git a/go/core/internal/controller/translator/mutate_test.go b/go/core/internal/controller/translator/mutate_test.go new file mode 100644 index 000000000..643250aa3 --- /dev/null +++ b/go/core/internal/controller/translator/mutate_test.go @@ -0,0 +1,130 @@ +package translator + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + policyv1 "k8s.io/api/policy/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func pdbWithSpec(spec policyv1.PodDisruptionBudgetSpec) *policyv1.PodDisruptionBudget { + return &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{Name: "agent", Namespace: "test"}, + Spec: spec, + } +} + +// TestMutateFuncFor_PodDisruptionBudget_SwitchingThresholdClearsTheOther is the regression +// test for the reason PodDisruptionBudget needs an explicit case in MutateFuncFor. +// +// minAvailable and maxUnavailable are mutually exclusive and the API server rejects a spec +// that sets both. The generic mergeWithOverride fallback cannot express the transition: +// mergo only writes keys present in the desired object, so the field being moved away from +// survives on the existing object and the update fails. +func TestMutateFuncFor_PodDisruptionBudget_SwitchingThresholdClearsTheOther(t *testing.T) { + existing := pdbWithSpec(policyv1.PodDisruptionBudgetSpec{ + MinAvailable: new(intstr.FromInt32(2)), + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"kagent": "agent"}}, + }) + desired := pdbWithSpec(policyv1.PodDisruptionBudgetSpec{ + MaxUnavailable: new(intstr.FromInt32(1)), + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"kagent": "agent"}}, + }) + + require.NoError(t, MutateFuncFor(existing, desired)()) + + assert.Nil(t, existing.Spec.MinAvailable, + "minAvailable must be cleared when the agent switches to maxUnavailable") + require.NotNil(t, existing.Spec.MaxUnavailable) + assert.Equal(t, intstr.FromInt32(1), *existing.Spec.MaxUnavailable) +} + +func TestMutateFuncFor_PodDisruptionBudget_SwitchingBackClearsMaxUnavailable(t *testing.T) { + existing := pdbWithSpec(policyv1.PodDisruptionBudgetSpec{ + MaxUnavailable: new(intstr.FromInt32(1)), + }) + desired := pdbWithSpec(policyv1.PodDisruptionBudgetSpec{ + MinAvailable: new(intstr.FromString("50%")), + }) + + require.NoError(t, MutateFuncFor(existing, desired)()) + + assert.Nil(t, existing.Spec.MaxUnavailable, + "maxUnavailable must be cleared when the agent switches to minAvailable") + require.NotNil(t, existing.Spec.MinAvailable) + assert.Equal(t, intstr.FromString("50%"), *existing.Spec.MinAvailable) +} + +// TestMutateFuncFor_PodDisruptionBudget_ClearsUnhealthyPodEvictionPolicy covers the same +// unset-a-field problem for the optional eviction policy. +func TestMutateFuncFor_PodDisruptionBudget_ClearsUnhealthyPodEvictionPolicy(t *testing.T) { + policy := policyv1.AlwaysAllow + existing := pdbWithSpec(policyv1.PodDisruptionBudgetSpec{ + MaxUnavailable: new(intstr.FromInt32(1)), + UnhealthyPodEvictionPolicy: &policy, + }) + desired := pdbWithSpec(policyv1.PodDisruptionBudgetSpec{ + MaxUnavailable: new(intstr.FromInt32(1)), + }) + + require.NoError(t, MutateFuncFor(existing, desired)()) + + assert.Nil(t, existing.Spec.UnhealthyPodEvictionPolicy, + "removing unhealthyPodEvictionPolicy from the agent spec must clear it on the object") +} + +// TestMutateFuncFor_PodDisruptionBudget_UpdatesSelector guards against a stale selector +// surviving an update, which would leave the budget protecting the wrong pods. +func TestMutateFuncFor_PodDisruptionBudget_UpdatesSelector(t *testing.T) { + existing := pdbWithSpec(policyv1.PodDisruptionBudgetSpec{ + MaxUnavailable: new(intstr.FromInt32(1)), + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"kagent": "old"}}, + }) + desired := pdbWithSpec(policyv1.PodDisruptionBudgetSpec{ + MaxUnavailable: new(intstr.FromInt32(1)), + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"kagent": "new"}}, + }) + + require.NoError(t, MutateFuncFor(existing, desired)()) + + require.NotNil(t, existing.Spec.Selector) + assert.Equal(t, map[string]string{"kagent": "new"}, existing.Spec.Selector.MatchLabels) +} + +// TestMutateFuncFor_PodDisruptionBudget_PreservesExistingLabelsAndAnnotations asserts the +// shared metadata handling in MutateFuncFor still applies: labels and annotations added +// out-of-band survive, while desired keys win on conflict. +func TestMutateFuncFor_PodDisruptionBudget_PreservesExistingLabelsAndAnnotations(t *testing.T) { + existing := &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{ + Name: "agent", + Namespace: "test", + Labels: map[string]string{"added-by-operator": "keep", "shared": "old"}, + Annotations: map[string]string{"added-by-operator": "keep", "shared": "old"}, + }, + Spec: policyv1.PodDisruptionBudgetSpec{ + MaxUnavailable: new(intstr.FromInt32(1)), + }, + } + desired := &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{ + Name: "agent", + Namespace: "test", + Labels: map[string]string{"shared": "new"}, + Annotations: map[string]string{"shared": "new"}, + }, + Spec: policyv1.PodDisruptionBudgetSpec{ + MaxUnavailable: new(intstr.FromInt32(1)), + }, + } + + require.NoError(t, MutateFuncFor(existing, desired)()) + + assert.Equal(t, "keep", existing.Labels["added-by-operator"]) + assert.Equal(t, "new", existing.Labels["shared"]) + assert.Equal(t, "keep", existing.Annotations["added-by-operator"]) + assert.Equal(t, "new", existing.Annotations["shared"]) +} diff --git a/go/core/test/e2e/invoke_api_test.go b/go/core/test/e2e/invoke_api_test.go index 41a3af52e..290beac6d 100644 --- a/go/core/test/e2e/invoke_api_test.go +++ b/go/core/test/e2e/invoke_api_test.go @@ -15,6 +15,7 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" k8s_runtime "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/wait" @@ -87,6 +88,8 @@ func setupK8sClient(t *testing.T, includeV1Alpha1 bool) client.Client { require.NoError(t, err) err = appsv1.AddToScheme(scheme) require.NoError(t, err) + err = policyv1.AddToScheme(scheme) + require.NoError(t, err) cli, err := client.New(cfg, client.Options{ Scheme: scheme, @@ -169,6 +172,9 @@ type AgentOptions struct { Runtime *v1alpha2.DeclarativeRuntime Memory *v1alpha2.MemorySpec PromptTemplate *v1alpha2.PromptTemplateSpec + Replicas *int32 + // PodDisruptionBudget requests a budget for the agent pods. Nil means no budget. + PodDisruptionBudget *v1alpha2.PodDisruptionBudgetSpec IconURL string DocumentationURL string @@ -527,6 +533,14 @@ func generateAgent(modelConfigName string, tools []*v1alpha2.Tool, opts AgentOpt agent.Spec.Declarative.PromptTemplate = opts.PromptTemplate } + if opts.Replicas != nil { + agent.Spec.Declarative.Deployment.Replicas = opts.Replicas + } + + if opts.PodDisruptionBudget != nil { + agent.Spec.Declarative.Deployment.PodDisruptionBudget = opts.PodDisruptionBudget + } + agent.Spec.IconURL = opts.IconURL agent.Spec.DocumentationURL = opts.DocumentationURL agent.Spec.Version = opts.Version diff --git a/go/core/test/e2e/poddisruptionbudget_test.go b/go/core/test/e2e/poddisruptionbudget_test.go new file mode 100644 index 000000000..2f806fd65 --- /dev/null +++ b/go/core/test/e2e/poddisruptionbudget_test.go @@ -0,0 +1,137 @@ +package e2e_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + policyv1 "k8s.io/api/policy/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" +) + +// TestE2EAgentPodDisruptionBudget covers the full lifecycle of the optional agent +// PodDisruptionBudget: it is created with the agent, its selector actually matches the +// agent's pods, updates are applied in place, and removing the field from the spec prunes +// the budget instead of orphaning it. +func TestE2EAgentPodDisruptionBudget(t *testing.T) { + baseURL, stopServer := setupMockServer(t, "mocks/invoke_inline_agent.json") + defer stopServer() + + cli := setupK8sClient(t, false) + modelCfg := setupModelConfig(t, cli, baseURL) + + maxUnavailable := intstr.FromInt32(1) + alwaysAllow := policyv1.AlwaysAllow + agent := setupAgentWithOptions(t, cli, modelCfg.Name, nil, AgentOptions{ + Name: "pdb-agent", + Replicas: new(int32(2)), + PodDisruptionBudget: &v1alpha2.PodDisruptionBudgetSpec{ + MaxUnavailable: &maxUnavailable, + UnhealthyPodEvictionPolicy: &alwaysAllow, + }, + }) + + pdbKey := types.NamespacedName{Name: agent.Name, Namespace: agent.Namespace} + + // The budget is created alongside the Deployment. + pdb := &policyv1.PodDisruptionBudget{} + require.EventuallyWithT(t, func(c *assert.CollectT) { + assert.NoError(c, cli.Get(t.Context(), pdbKey, pdb)) + }, 2*time.Minute, time.Second, "PodDisruptionBudget should be created for the agent") + + require.NotNil(t, pdb.Spec.MaxUnavailable) + assert.Equal(t, intstr.FromInt32(1), *pdb.Spec.MaxUnavailable) + assert.Nil(t, pdb.Spec.MinAvailable) + require.NotNil(t, pdb.Spec.UnhealthyPodEvictionPolicy) + assert.Equal(t, policyv1.AlwaysAllow, *pdb.Spec.UnhealthyPodEvictionPolicy) + + // It must be owned by the agent, so `kubectl delete agent` garbage collects it. + require.Len(t, pdb.OwnerReferences, 1) + assert.Equal(t, "Agent", pdb.OwnerReferences[0].Kind) + assert.Equal(t, agent.Name, pdb.OwnerReferences[0].Name) + + // A budget whose selector does not match the Deployment protects nothing, so assert + // against the live Deployment rather than trusting the translator. + deployment := &appsv1.Deployment{} + require.NoError(t, cli.Get(t.Context(), pdbKey, deployment)) + require.NotNil(t, deployment.Spec.Selector) + require.NotNil(t, pdb.Spec.Selector) + assert.Equal(t, deployment.Spec.Selector.MatchLabels, pdb.Spec.Selector.MatchLabels, + "budget selector must match the Deployment selector") + + // The API server reports how many pods the budget actually matches, which is the real + // proof the selector is correct. + require.EventuallyWithT(t, func(c *assert.CollectT) { + live := &policyv1.PodDisruptionBudget{} + if !assert.NoError(c, cli.Get(t.Context(), pdbKey, live)) { + return + } + assert.Equal(c, int32(2), live.Status.ExpectedPods, + "budget should match the agent's 2 pods") + }, 2*time.Minute, 2*time.Second) + + t.Run("switching to minAvailable updates the budget in place", func(t *testing.T) { + minAvailable := intstr.FromInt32(1) + updateAgentPDB(t, cli, pdbKey, &v1alpha2.PodDisruptionBudgetSpec{ + MinAvailable: new(minAvailable), + }) + + require.EventuallyWithT(t, func(c *assert.CollectT) { + live := &policyv1.PodDisruptionBudget{} + if !assert.NoError(c, cli.Get(t.Context(), pdbKey, live)) { + return + } + if !assert.NotNil(c, live.Spec.MinAvailable) { + return + } + assert.Equal(c, intstr.FromInt32(1), *live.Spec.MinAvailable) + // The threshold being moved away from has to be cleared, otherwise the + // API server rejects the update for setting both. + assert.Nil(c, live.Spec.MaxUnavailable) + // Dropping the policy from the spec must clear it on the object too. + assert.Nil(c, live.Spec.UnhealthyPodEvictionPolicy) + }, 2*time.Minute, time.Second) + }) + + t.Run("removing the field prunes the budget", func(t *testing.T) { + updateAgentPDB(t, cli, pdbKey, nil) + + require.EventuallyWithT(t, func(c *assert.CollectT) { + live := &policyv1.PodDisruptionBudget{} + err := cli.Get(t.Context(), pdbKey, live) + assert.True(c, apierrors.IsNotFound(err), + "budget should be pruned once the agent stops requesting one, got err=%v", err) + }, 2*time.Minute, time.Second) + + // Pruning the budget must not disturb the Deployment. + live := &appsv1.Deployment{} + assert.NoError(t, cli.Get(t.Context(), pdbKey, live)) + }) +} + +// updateAgentPDB sets (or clears, when spec is nil) the agent's podDisruptionBudget, +// retrying on conflict since the controller writes status concurrently. +func updateAgentPDB( + t *testing.T, + cli client.Client, + key types.NamespacedName, + spec *v1alpha2.PodDisruptionBudgetSpec, +) { + t.Helper() + + require.EventuallyWithT(t, func(c *assert.CollectT) { + agent := &v1alpha2.Agent{} + if !assert.NoError(c, cli.Get(t.Context(), key, agent)) { + return + } + agent.Spec.Declarative.Deployment.PodDisruptionBudget = spec + assert.NoError(c, cli.Update(t.Context(), agent)) + }, 30*time.Second, time.Second) +} diff --git a/helm/kagent-crds/templates/kagent.dev_agents.yaml b/helm/kagent-crds/templates/kagent.dev_agents.yaml index f9d7eefcf..79416207d 100644 --- a/helm/kagent-crds/templates/kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agents.yaml @@ -5217,6 +5217,44 @@ spec: description: NodeSelector restricts the nodes the agent pods can be scheduled on. type: object + podDisruptionBudget: + description: |- + PodDisruptionBudget requests a PodDisruptionBudget for the agent pods, limiting how + many may be disrupted by voluntary evictions such as node drains. Omit the field to + create no budget; removing it later deletes the budget. This has no effect when the + agent runs with the Sandbox workload mode, as no Deployment is created in that mode. + properties: + maxUnavailable: + anyOf: + - type: integer + - type: string + description: |- + MaxUnavailable is the number or percentage of pods that may be unavailable. + Mutually exclusive with MinAvailable. + x-kubernetes-int-or-string: true + minAvailable: + anyOf: + - type: integer + - type: string + description: |- + MinAvailable is the number or percentage of pods that must remain available. + Mutually exclusive with MaxUnavailable. + x-kubernetes-int-or-string: true + unhealthyPodEvictionPolicy: + description: |- + UnhealthyPodEvictionPolicy defines when unhealthy pods should be considered for + eviction. AlwaysAllow permits evicting unhealthy pods even once the budget is + exhausted, which stops a crash-looping pod from wedging a node drain. + Defaults to the Kubernetes default of IfHealthyBudget. Requires Kubernetes >= 1.27. + enum: + - IfHealthyBudget + - AlwaysAllow + type: string + type: object + x-kubernetes-validations: + - message: exactly one of minAvailable or maxUnavailable must + be set + rule: has(self.minAvailable) != has(self.maxUnavailable) podSecurityContext: description: |- PodSecurityContext holds pod-level security attributes and common container settings. @@ -10619,6 +10657,44 @@ spec: description: NodeSelector restricts the nodes the agent pods can be scheduled on. type: object + podDisruptionBudget: + description: |- + PodDisruptionBudget requests a PodDisruptionBudget for the agent pods, limiting how + many may be disrupted by voluntary evictions such as node drains. Omit the field to + create no budget; removing it later deletes the budget. This has no effect when the + agent runs with the Sandbox workload mode, as no Deployment is created in that mode. + properties: + maxUnavailable: + anyOf: + - type: integer + - type: string + description: |- + MaxUnavailable is the number or percentage of pods that may be unavailable. + Mutually exclusive with MinAvailable. + x-kubernetes-int-or-string: true + minAvailable: + anyOf: + - type: integer + - type: string + description: |- + MinAvailable is the number or percentage of pods that must remain available. + Mutually exclusive with MaxUnavailable. + x-kubernetes-int-or-string: true + unhealthyPodEvictionPolicy: + description: |- + UnhealthyPodEvictionPolicy defines when unhealthy pods should be considered for + eviction. AlwaysAllow permits evicting unhealthy pods even once the budget is + exhausted, which stops a crash-looping pod from wedging a node drain. + Defaults to the Kubernetes default of IfHealthyBudget. Requires Kubernetes >= 1.27. + enum: + - IfHealthyBudget + - AlwaysAllow + type: string + type: object + x-kubernetes-validations: + - message: exactly one of minAvailable or maxUnavailable must + be set + rule: has(self.minAvailable) != has(self.maxUnavailable) podSecurityContext: description: |- PodSecurityContext holds pod-level security attributes and common container settings. diff --git a/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml b/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml index caf39a509..df5989aa9 100644 --- a/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml @@ -2874,6 +2874,44 @@ spec: description: NodeSelector restricts the nodes the agent pods can be scheduled on. type: object + podDisruptionBudget: + description: |- + PodDisruptionBudget requests a PodDisruptionBudget for the agent pods, limiting how + many may be disrupted by voluntary evictions such as node drains. Omit the field to + create no budget; removing it later deletes the budget. This has no effect when the + agent runs with the Sandbox workload mode, as no Deployment is created in that mode. + properties: + maxUnavailable: + anyOf: + - type: integer + - type: string + description: |- + MaxUnavailable is the number or percentage of pods that may be unavailable. + Mutually exclusive with MinAvailable. + x-kubernetes-int-or-string: true + minAvailable: + anyOf: + - type: integer + - type: string + description: |- + MinAvailable is the number or percentage of pods that must remain available. + Mutually exclusive with MaxUnavailable. + x-kubernetes-int-or-string: true + unhealthyPodEvictionPolicy: + description: |- + UnhealthyPodEvictionPolicy defines when unhealthy pods should be considered for + eviction. AlwaysAllow permits evicting unhealthy pods even once the budget is + exhausted, which stops a crash-looping pod from wedging a node drain. + Defaults to the Kubernetes default of IfHealthyBudget. Requires Kubernetes >= 1.27. + enum: + - IfHealthyBudget + - AlwaysAllow + type: string + type: object + x-kubernetes-validations: + - message: exactly one of minAvailable or maxUnavailable must + be set + rule: has(self.minAvailable) != has(self.maxUnavailable) podSecurityContext: description: |- PodSecurityContext holds pod-level security attributes and common container settings. @@ -8276,6 +8314,44 @@ spec: description: NodeSelector restricts the nodes the agent pods can be scheduled on. type: object + podDisruptionBudget: + description: |- + PodDisruptionBudget requests a PodDisruptionBudget for the agent pods, limiting how + many may be disrupted by voluntary evictions such as node drains. Omit the field to + create no budget; removing it later deletes the budget. This has no effect when the + agent runs with the Sandbox workload mode, as no Deployment is created in that mode. + properties: + maxUnavailable: + anyOf: + - type: integer + - type: string + description: |- + MaxUnavailable is the number or percentage of pods that may be unavailable. + Mutually exclusive with MinAvailable. + x-kubernetes-int-or-string: true + minAvailable: + anyOf: + - type: integer + - type: string + description: |- + MinAvailable is the number or percentage of pods that must remain available. + Mutually exclusive with MaxUnavailable. + x-kubernetes-int-or-string: true + unhealthyPodEvictionPolicy: + description: |- + UnhealthyPodEvictionPolicy defines when unhealthy pods should be considered for + eviction. AlwaysAllow permits evicting unhealthy pods even once the budget is + exhausted, which stops a crash-looping pod from wedging a node drain. + Defaults to the Kubernetes default of IfHealthyBudget. Requires Kubernetes >= 1.27. + enum: + - IfHealthyBudget + - AlwaysAllow + type: string + type: object + x-kubernetes-validations: + - message: exactly one of minAvailable or maxUnavailable must + be set + rule: has(self.minAvailable) != has(self.maxUnavailable) podSecurityContext: description: |- PodSecurityContext holds pod-level security attributes and common container settings. diff --git a/helm/kagent/templates/rbac/getter-role.yaml b/helm/kagent/templates/rbac/getter-role.yaml index ceab5ec9a..720a06fa0 100644 --- a/helm/kagent/templates/rbac/getter-role.yaml +++ b/helm/kagent/templates/rbac/getter-role.yaml @@ -100,6 +100,16 @@ - get - list - watch +# The `policy` group is not covered by the wildcard grants above. Required for the +# owned-resource watch and owner index over agent PodDisruptionBudgets. +- apiGroups: + - policy + resources: + - poddisruptionbudgets + verbs: + - get + - list + - watch - apiGroups: - agents.x-k8s.io resources: diff --git a/helm/kagent/templates/rbac/writer-role.yaml b/helm/kagent/templates/rbac/writer-role.yaml index 551f52d5f..ca43ebf33 100644 --- a/helm/kagent/templates/rbac/writer-role.yaml +++ b/helm/kagent/templates/rbac/writer-role.yaml @@ -66,6 +66,18 @@ - update - patch - delete +# The `policy` group is not covered by the wildcard grants above. Required so the +# controller can manage the PodDisruptionBudget an Agent requests via +# spec.*.deployment.podDisruptionBudget. +- apiGroups: + - policy + resources: + - poddisruptionbudgets + verbs: + - create + - update + - patch + - delete - apiGroups: - agents.x-k8s.io resources: