From 62b7511e07559583f759d4df3258395919713889 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:07:30 +0000 Subject: [PATCH 1/2] Initial plan From e9c8bc7189c27838e3a4a5f3b09ecd1fb11da833 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:22:55 +0000 Subject: [PATCH 2/2] feat: add commonMetadata and podMetadata to Agent and ToolServer specs - Add EmbeddedMetadata struct (labels + annotations) to api/v1alpha1/common_types.go - Add commonMetadata and podMetadata fields to AgentSpec and ToolServerSpec - Add commonMetadata and podMetadata fields to AgentGatewaySpec, AiGatewaySpec, ToolGatewaySpec (fields only) - Regenerate CRDs and deep copy via make manifests && make generate - Add helper functions applyCommonMetadataToObjectMeta and buildPodTemplateMetadata to util.go - Update Agent reconciler to apply metadata to Deployment, Service, and pod template - Update ToolServer reconciler to apply metadata to Deployment, Service, and pod template - Add integration tests for all metadata scenarios (162 tests all passing) Co-authored-by: g3force <779094+g3force@users.noreply.github.com> --- api/v1alpha1/agent_types.go | 10 + api/v1alpha1/agentgateway_types.go | 10 + api/v1alpha1/aigateway_types.go | 10 + api/v1alpha1/common_types.go | 28 ++ api/v1alpha1/toolgateway_types.go | 10 + api/v1alpha1/toolserver_types.go | 10 + api/v1alpha1/zz_generated.deepcopy.go | 79 ++++ ...untime.agentic-layer.ai_agentgateways.yaml | 36 ++ .../runtime.agentic-layer.ai_agents.yaml | 36 ++ .../runtime.agentic-layer.ai_aigateways.yaml | 36 ++ ...runtime.agentic-layer.ai_toolgateways.yaml | 36 ++ .../runtime.agentic-layer.ai_toolservers.yaml | 36 ++ go.mod | 2 +- go.sum | 6 - internal/controller/agent_metadata_test.go | 417 ++++++++++++++++++ internal/controller/agent_reconciler.go | 14 +- internal/controller/toolserver_reconciler.go | 12 +- internal/controller/util.go | 56 +++ 18 files changed, 830 insertions(+), 14 deletions(-) create mode 100644 api/v1alpha1/common_types.go create mode 100644 internal/controller/agent_metadata_test.go diff --git a/api/v1alpha1/agent_types.go b/api/v1alpha1/agent_types.go index 63ce274..d532c3f 100644 --- a/api/v1alpha1/agent_types.go +++ b/api/v1alpha1/agent_types.go @@ -182,6 +182,16 @@ type AgentSpec struct { // Resources defines the compute resource requirements for the agent container. // +optional Resources *corev1.ResourceRequirements `json:"resources,omitempty"` + + // CommonMetadata defines labels and annotations to be applied to the Deployment and Service + // resources created for this agent, as well as the pod template. + // +optional + CommonMetadata *EmbeddedMetadata `json:"commonMetadata,omitempty"` + + // PodMetadata defines labels and annotations to be applied only to the pod template + // of the Deployment created for this agent. + // +optional + PodMetadata *EmbeddedMetadata `json:"podMetadata,omitempty"` } // AgentStatus defines the observed state of Agent. diff --git a/api/v1alpha1/agentgateway_types.go b/api/v1alpha1/agentgateway_types.go index d8399dc..804c61e 100644 --- a/api/v1alpha1/agentgateway_types.go +++ b/api/v1alpha1/agentgateway_types.go @@ -45,6 +45,16 @@ type AgentGatewaySpec struct { // This allows loading variables from ConfigMaps and Secrets. // +optional EnvFrom []corev1.EnvFromSource `json:"envFrom,omitempty"` + + // CommonMetadata defines labels and annotations to be applied to the Deployment and Service + // resources created for this gateway, as well as the pod template. + // +optional + CommonMetadata *EmbeddedMetadata `json:"commonMetadata,omitempty"` + + // PodMetadata defines labels and annotations to be applied only to the pod template + // of the Deployment created for this gateway. + // +optional + PodMetadata *EmbeddedMetadata `json:"podMetadata,omitempty"` } // AgentGatewayStatus defines the observed state of AgentGateway diff --git a/api/v1alpha1/aigateway_types.go b/api/v1alpha1/aigateway_types.go index 1845c57..cc57051 100644 --- a/api/v1alpha1/aigateway_types.go +++ b/api/v1alpha1/aigateway_types.go @@ -47,6 +47,16 @@ type AiGatewaySpec struct { // This allows loading variables from ConfigMaps and Secrets. // +optional EnvFrom []corev1.EnvFromSource `json:"envFrom,omitempty"` + + // CommonMetadata defines labels and annotations to be applied to the Deployment and Service + // resources created for this gateway, as well as the pod template. + // +optional + CommonMetadata *EmbeddedMetadata `json:"commonMetadata,omitempty"` + + // PodMetadata defines labels and annotations to be applied only to the pod template + // of the Deployment created for this gateway. + // +optional + PodMetadata *EmbeddedMetadata `json:"podMetadata,omitempty"` } type AiModel struct { diff --git a/api/v1alpha1/common_types.go b/api/v1alpha1/common_types.go new file mode 100644 index 0000000..8c55fd9 --- /dev/null +++ b/api/v1alpha1/common_types.go @@ -0,0 +1,28 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +// EmbeddedMetadata defines labels and annotations that can be applied to Kubernetes resources. +type EmbeddedMetadata struct { + // Labels is a map of key/value pairs to be applied to the resource. + // +optional + Labels map[string]string `json:"labels,omitempty"` + + // Annotations is a map of key/value pairs to be applied to the resource. + // +optional + Annotations map[string]string `json:"annotations,omitempty"` +} diff --git a/api/v1alpha1/toolgateway_types.go b/api/v1alpha1/toolgateway_types.go index 6c18ca7..493ae09 100644 --- a/api/v1alpha1/toolgateway_types.go +++ b/api/v1alpha1/toolgateway_types.go @@ -36,6 +36,16 @@ type ToolGatewaySpec struct { // This allows loading variables from ConfigMaps and Secrets. // +optional EnvFrom []corev1.EnvFromSource `json:"envFrom,omitempty"` + + // CommonMetadata defines labels and annotations to be applied to the Deployment and Service + // resources created for this gateway, as well as the pod template. + // +optional + CommonMetadata *EmbeddedMetadata `json:"commonMetadata,omitempty"` + + // PodMetadata defines labels and annotations to be applied only to the pod template + // of the Deployment created for this gateway. + // +optional + PodMetadata *EmbeddedMetadata `json:"podMetadata,omitempty"` } // ToolGatewayStatus defines the observed state of ToolGateway diff --git a/api/v1alpha1/toolserver_types.go b/api/v1alpha1/toolserver_types.go index 5762465..08ed75b 100644 --- a/api/v1alpha1/toolserver_types.go +++ b/api/v1alpha1/toolserver_types.go @@ -89,6 +89,16 @@ type ToolServerSpec struct { // If Namespace is not specified, defaults to the same namespace as the ToolServer. // +optional ToolGatewayRef *corev1.ObjectReference `json:"toolGatewayRef,omitempty"` + + // CommonMetadata defines labels and annotations to be applied to the Deployment and Service + // resources created for this tool server, as well as the pod template. + // +optional + CommonMetadata *EmbeddedMetadata `json:"commonMetadata,omitempty"` + + // PodMetadata defines labels and annotations to be applied only to the pod template + // of the Deployment created for this tool server. + // +optional + PodMetadata *EmbeddedMetadata `json:"podMetadata,omitempty"` } // ToolServerStatus defines the observed state of ToolServer. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 3a4bed6..bfc41e8 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -235,6 +235,16 @@ func (in *AgentGatewaySpec) DeepCopyInto(out *AgentGatewaySpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.CommonMetadata != nil { + in, out := &in.CommonMetadata, &out.CommonMetadata + *out = new(EmbeddedMetadata) + (*in).DeepCopyInto(*out) + } + if in.PodMetadata != nil { + in, out := &in.PodMetadata, &out.PodMetadata + *out = new(EmbeddedMetadata) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentGatewaySpec. @@ -482,6 +492,16 @@ func (in *AgentSpec) DeepCopyInto(out *AgentSpec) { *out = new(v1.ResourceRequirements) (*in).DeepCopyInto(*out) } + if in.CommonMetadata != nil { + in, out := &in.CommonMetadata, &out.CommonMetadata + *out = new(EmbeddedMetadata) + (*in).DeepCopyInto(*out) + } + if in.PodMetadata != nil { + in, out := &in.PodMetadata, &out.PodMetadata + *out = new(EmbeddedMetadata) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentSpec. @@ -860,6 +880,16 @@ func (in *AiGatewaySpec) DeepCopyInto(out *AiGatewaySpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.CommonMetadata != nil { + in, out := &in.CommonMetadata, &out.CommonMetadata + *out = new(EmbeddedMetadata) + (*in).DeepCopyInto(*out) + } + if in.PodMetadata != nil { + in, out := &in.PodMetadata, &out.PodMetadata + *out = new(EmbeddedMetadata) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AiGatewaySpec. @@ -909,6 +939,35 @@ func (in *AiModel) DeepCopy() *AiModel { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EmbeddedMetadata) DeepCopyInto(out *EmbeddedMetadata) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddedMetadata. +func (in *EmbeddedMetadata) DeepCopy() *EmbeddedMetadata { + if in == nil { + return nil + } + out := new(EmbeddedMetadata) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SubAgent) DeepCopyInto(out *SubAgent) { *out = *in @@ -1101,6 +1160,16 @@ func (in *ToolGatewaySpec) DeepCopyInto(out *ToolGatewaySpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.CommonMetadata != nil { + in, out := &in.CommonMetadata, &out.CommonMetadata + *out = new(EmbeddedMetadata) + (*in).DeepCopyInto(*out) + } + if in.PodMetadata != nil { + in, out := &in.PodMetadata, &out.PodMetadata + *out = new(EmbeddedMetadata) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolGatewaySpec. @@ -1236,6 +1305,16 @@ func (in *ToolServerSpec) DeepCopyInto(out *ToolServerSpec) { *out = new(v1.ObjectReference) **out = **in } + if in.CommonMetadata != nil { + in, out := &in.CommonMetadata, &out.CommonMetadata + *out = new(EmbeddedMetadata) + (*in).DeepCopyInto(*out) + } + if in.PodMetadata != nil { + in, out := &in.PodMetadata, &out.PodMetadata + *out = new(EmbeddedMetadata) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolServerSpec. diff --git a/config/crd/bases/runtime.agentic-layer.ai_agentgateways.yaml b/config/crd/bases/runtime.agentic-layer.ai_agentgateways.yaml index 0bc794f..df90329 100644 --- a/config/crd/bases/runtime.agentic-layer.ai_agentgateways.yaml +++ b/config/crd/bases/runtime.agentic-layer.ai_agentgateways.yaml @@ -44,6 +44,24 @@ spec: AgentGatewayClassName specifies which AgentGatewayClass to use for this gateway instance. This is only needed if multiple gateway classes are defined in the cluster. type: string + commonMetadata: + description: |- + CommonMetadata defines labels and annotations to be applied to the Deployment and Service + resources created for this gateway, as well as the pod template. + properties: + annotations: + additionalProperties: + type: string + description: Annotations is a map of key/value pairs to be applied + to the resource. + type: object + labels: + additionalProperties: + type: string + description: Labels is a map of key/value pairs to be applied + to the resource. + type: object + type: object env: description: |- Environment variables to pass to the AgentGateway container. @@ -252,6 +270,24 @@ spec: x-kubernetes-map-type: atomic type: object type: array + podMetadata: + description: |- + PodMetadata defines labels and annotations to be applied only to the pod template + of the Deployment created for this gateway. + properties: + annotations: + additionalProperties: + type: string + description: Annotations is a map of key/value pairs to be applied + to the resource. + type: object + labels: + additionalProperties: + type: string + description: Labels is a map of key/value pairs to be applied + to the resource. + type: object + type: object replicas: default: 1 description: Replicas is the number of gateway replicas diff --git a/config/crd/bases/runtime.agentic-layer.ai_agents.yaml b/config/crd/bases/runtime.agentic-layer.ai_agents.yaml index 1f7937c..d6c876e 100644 --- a/config/crd/bases/runtime.agentic-layer.ai_agents.yaml +++ b/config/crd/bases/runtime.agentic-layer.ai_agents.yaml @@ -90,6 +90,24 @@ spec: type: string type: object x-kubernetes-map-type: atomic + commonMetadata: + description: |- + CommonMetadata defines labels and annotations to be applied to the Deployment and Service + resources created for this agent, as well as the pod template. + properties: + annotations: + additionalProperties: + type: string + description: Annotations is a map of key/value pairs to be applied + to the resource. + type: object + labels: + additionalProperties: + type: string + description: Labels is a map of key/value pairs to be applied + to the resource. + type: object + type: object description: description: |- Description provides a description of the agent. @@ -329,6 +347,24 @@ spec: This is passed as AGENT_MODEL environment variable to the agent. Defaults to the agents default model if not specified. type: string + podMetadata: + description: |- + PodMetadata defines labels and annotations to be applied only to the pod template + of the Deployment created for this agent. + properties: + annotations: + additionalProperties: + type: string + description: Annotations is a map of key/value pairs to be applied + to the resource. + type: object + labels: + additionalProperties: + type: string + description: Labels is a map of key/value pairs to be applied + to the resource. + type: object + type: object protocols: description: Protocols defines the protocols supported by the agent items: diff --git a/config/crd/bases/runtime.agentic-layer.ai_aigateways.yaml b/config/crd/bases/runtime.agentic-layer.ai_aigateways.yaml index 4b6243d..fcc3091 100644 --- a/config/crd/bases/runtime.agentic-layer.ai_aigateways.yaml +++ b/config/crd/bases/runtime.agentic-layer.ai_aigateways.yaml @@ -64,6 +64,24 @@ spec: type: object minItems: 1 type: array + commonMetadata: + description: |- + CommonMetadata defines labels and annotations to be applied to the Deployment and Service + resources created for this gateway, as well as the pod template. + properties: + annotations: + additionalProperties: + type: string + description: Annotations is a map of key/value pairs to be applied + to the resource. + type: object + labels: + additionalProperties: + type: string + description: Labels is a map of key/value pairs to be applied + to the resource. + type: object + type: object env: description: |- Environment variables to pass to the AI gateway container. @@ -272,6 +290,24 @@ spec: x-kubernetes-map-type: atomic type: object type: array + podMetadata: + description: |- + PodMetadata defines labels and annotations to be applied only to the pod template + of the Deployment created for this gateway. + properties: + annotations: + additionalProperties: + type: string + description: Annotations is a map of key/value pairs to be applied + to the resource. + type: object + labels: + additionalProperties: + type: string + description: Labels is a map of key/value pairs to be applied + to the resource. + type: object + type: object port: default: 80 description: Port on which the AI gateway will be exposed. diff --git a/config/crd/bases/runtime.agentic-layer.ai_toolgateways.yaml b/config/crd/bases/runtime.agentic-layer.ai_toolgateways.yaml index dce1b4e..ecbb704 100644 --- a/config/crd/bases/runtime.agentic-layer.ai_toolgateways.yaml +++ b/config/crd/bases/runtime.agentic-layer.ai_toolgateways.yaml @@ -39,6 +39,24 @@ spec: spec: description: ToolGatewaySpec defines the desired state of ToolGateway properties: + commonMetadata: + description: |- + CommonMetadata defines labels and annotations to be applied to the Deployment and Service + resources created for this gateway, as well as the pod template. + properties: + annotations: + additionalProperties: + type: string + description: Annotations is a map of key/value pairs to be applied + to the resource. + type: object + labels: + additionalProperties: + type: string + description: Labels is a map of key/value pairs to be applied + to the resource. + type: object + type: object env: description: |- Environment variables to pass to the ToolGateway container. @@ -247,6 +265,24 @@ spec: x-kubernetes-map-type: atomic type: object type: array + podMetadata: + description: |- + PodMetadata defines labels and annotations to be applied only to the pod template + of the Deployment created for this gateway. + properties: + annotations: + additionalProperties: + type: string + description: Annotations is a map of key/value pairs to be applied + to the resource. + type: object + labels: + additionalProperties: + type: string + description: Labels is a map of key/value pairs to be applied + to the resource. + type: object + type: object toolGatewayClassName: description: |- ToolGatewayClassName specifies which ToolGatewayClass to use for this gateway instance. diff --git a/config/crd/bases/runtime.agentic-layer.ai_toolservers.yaml b/config/crd/bases/runtime.agentic-layer.ai_toolservers.yaml index b09cfd6..67846e4 100644 --- a/config/crd/bases/runtime.agentic-layer.ai_toolservers.yaml +++ b/config/crd/bases/runtime.agentic-layer.ai_toolservers.yaml @@ -59,6 +59,24 @@ spec: items: type: string type: array + commonMetadata: + description: |- + CommonMetadata defines labels and annotations to be applied to the Deployment and Service + resources created for this tool server, as well as the pod template. + properties: + annotations: + additionalProperties: + type: string + description: Annotations is a map of key/value pairs to be applied + to the resource. + type: object + labels: + additionalProperties: + type: string + description: Labels is a map of key/value pairs to be applied + to the resource. + type: object + type: object env: description: Env defines additional environment variables to be injected into the tool server container @@ -276,6 +294,24 @@ spec: Must start with "/" if specified pattern: ^/.* type: string + podMetadata: + description: |- + PodMetadata defines labels and annotations to be applied only to the pod template + of the Deployment created for this tool server. + properties: + annotations: + additionalProperties: + type: string + description: Annotations is a map of key/value pairs to be applied + to the resource. + type: object + labels: + additionalProperties: + type: string + description: Labels is a map of key/value pairs to be applied + to the resource. + type: object + type: object port: description: |- Port is the port number for http/sse transports diff --git a/go.mod b/go.mod index 36f24fa..f492b34 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( k8s.io/api v0.35.2 k8s.io/apimachinery v0.35.2 k8s.io/client-go v0.35.2 + k8s.io/klog/v2 v2.130.1 sigs.k8s.io/controller-runtime v0.23.1 ) @@ -92,7 +93,6 @@ require ( k8s.io/apiextensions-apiserver v0.35.0 // indirect k8s.io/apiserver v0.35.0 // indirect k8s.io/component-base v0.35.0 // indirect - k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect diff --git a/go.sum b/go.sum index 5bf45fc..cec5e1a 100644 --- a/go.sum +++ b/go.sum @@ -231,20 +231,14 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= -k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw= k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60= k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= -k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= -k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= -k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= -k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o= k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g= k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= diff --git a/internal/controller/agent_metadata_test.go b/internal/controller/agent_metadata_test.go new file mode 100644 index 0000000..84f6dfb --- /dev/null +++ b/internal/controller/agent_metadata_test.go @@ -0,0 +1,417 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + runtimev1alpha1 "github.com/agentic-layer/agent-runtime-operator/api/v1alpha1" +) + +var _ = Describe("Agent Metadata", func() { + ctx := context.Background() + var reconciler *AgentReconciler + + BeforeEach(func() { + reconciler = &AgentReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + Expect(os.Setenv("POD_NAMESPACE", "default")).To(Succeed()) + }) + + AfterEach(func() { + agentList := &runtimev1alpha1.AgentList{} + Expect(k8sClient.List(ctx, agentList)).To(Succeed()) + for i := range agentList.Items { + _ = k8sClient.Delete(ctx, &agentList.Items[i]) + } + _ = os.Unsetenv("POD_NAMESPACE") + }) + + Describe("CommonMetadata", func() { + It("should apply commonMetadata labels and annotations to Deployment and Service", func() { + agent := &runtimev1alpha1.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-agent-common-meta", + Namespace: "default", + }, + Spec: runtimev1alpha1.AgentSpec{ + Framework: "google-adk", + Image: "image:tag", + Protocols: []runtimev1alpha1.AgentProtocol{ + {Type: runtimev1alpha1.A2AProtocol, Port: 8000, Path: "/"}, + }, + CommonMetadata: &runtimev1alpha1.EmbeddedMetadata{ + Labels: map[string]string{ + "team": "platform", + "environment": "test", + }, + Annotations: map[string]string{ + "owner": "platform-team", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, agent)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: "test-agent-common-meta", Namespace: "default"}, + }) + Expect(err).NotTo(HaveOccurred()) + + // Verify Deployment has commonMetadata labels and annotations + deployment := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-agent-common-meta", Namespace: "default"}, deployment)).To(Succeed()) + Expect(deployment.Labels).To(HaveKeyWithValue("team", "platform")) + Expect(deployment.Labels).To(HaveKeyWithValue("environment", "test")) + Expect(deployment.Annotations).To(HaveKeyWithValue("owner", "platform-team")) + + // Verify Pod template has commonMetadata labels + Expect(deployment.Spec.Template.Labels).To(HaveKeyWithValue("team", "platform")) + Expect(deployment.Spec.Template.Labels).To(HaveKeyWithValue("environment", "test")) + // Selector labels must still be present + Expect(deployment.Spec.Template.Labels).To(HaveKeyWithValue("app", "test-agent-common-meta")) + // Verify pod template annotations + Expect(deployment.Spec.Template.Annotations).To(HaveKeyWithValue("owner", "platform-team")) + + // Verify Service has commonMetadata labels and annotations + service := &corev1.Service{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-agent-common-meta", Namespace: "default"}, service)).To(Succeed()) + Expect(service.Labels).To(HaveKeyWithValue("team", "platform")) + Expect(service.Labels).To(HaveKeyWithValue("environment", "test")) + Expect(service.Annotations).To(HaveKeyWithValue("owner", "platform-team")) + }) + }) + + Describe("PodMetadata", func() { + It("should apply podMetadata labels and annotations only to the pod template", func() { + agent := &runtimev1alpha1.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-agent-pod-meta", + Namespace: "default", + }, + Spec: runtimev1alpha1.AgentSpec{ + Framework: "google-adk", + Image: "image:tag", + Protocols: []runtimev1alpha1.AgentProtocol{ + {Type: runtimev1alpha1.A2AProtocol, Port: 8000, Path: "/"}, + }, + PodMetadata: &runtimev1alpha1.EmbeddedMetadata{ + Labels: map[string]string{ + "pod-label": "pod-value", + }, + Annotations: map[string]string{ + "pod-annotation": "pod-annotation-value", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, agent)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: "test-agent-pod-meta", Namespace: "default"}, + }) + Expect(err).NotTo(HaveOccurred()) + + deployment := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-agent-pod-meta", Namespace: "default"}, deployment)).To(Succeed()) + + // Deployment itself should NOT have podMetadata labels + Expect(deployment.Labels).NotTo(HaveKey("pod-label")) + Expect(deployment.Annotations).To(BeNil()) + + // Pod template should have podMetadata labels and annotations + Expect(deployment.Spec.Template.Labels).To(HaveKeyWithValue("pod-label", "pod-value")) + Expect(deployment.Spec.Template.Annotations).To(HaveKeyWithValue("pod-annotation", "pod-annotation-value")) + // Selector labels must still be present + Expect(deployment.Spec.Template.Labels).To(HaveKeyWithValue("app", "test-agent-pod-meta")) + + // Service should NOT have podMetadata labels + service := &corev1.Service{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-agent-pod-meta", Namespace: "default"}, service)).To(Succeed()) + Expect(service.Labels).NotTo(HaveKey("pod-label")) + }) + }) + + Describe("CommonMetadata and PodMetadata combined", func() { + It("should apply both commonMetadata and podMetadata with podMetadata taking precedence on the pod template", func() { + agent := &runtimev1alpha1.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-agent-both-meta", + Namespace: "default", + }, + Spec: runtimev1alpha1.AgentSpec{ + Framework: "google-adk", + Image: "image:tag", + Protocols: []runtimev1alpha1.AgentProtocol{ + {Type: runtimev1alpha1.A2AProtocol, Port: 8000, Path: "/"}, + }, + CommonMetadata: &runtimev1alpha1.EmbeddedMetadata{ + Labels: map[string]string{ + "common-label": "common-value", + "shared-label": "common-shared", + }, + Annotations: map[string]string{ + "common-annotation": "common-value", + }, + }, + PodMetadata: &runtimev1alpha1.EmbeddedMetadata{ + Labels: map[string]string{ + "pod-only-label": "pod-value", + "shared-label": "pod-overridden", + }, + Annotations: map[string]string{ + "pod-annotation": "pod-value", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, agent)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: "test-agent-both-meta", Namespace: "default"}, + }) + Expect(err).NotTo(HaveOccurred()) + + deployment := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-agent-both-meta", Namespace: "default"}, deployment)).To(Succeed()) + + // Deployment should have commonMetadata labels but NOT podMetadata-only labels + Expect(deployment.Labels).To(HaveKeyWithValue("common-label", "common-value")) + Expect(deployment.Labels).NotTo(HaveKey("pod-only-label")) + + // Pod template should have both commonMetadata and podMetadata labels + // podMetadata overrides commonMetadata for the same key + Expect(deployment.Spec.Template.Labels).To(HaveKeyWithValue("common-label", "common-value")) + Expect(deployment.Spec.Template.Labels).To(HaveKeyWithValue("pod-only-label", "pod-value")) + Expect(deployment.Spec.Template.Labels).To(HaveKeyWithValue("shared-label", "pod-overridden")) + // Selector labels must still be present + Expect(deployment.Spec.Template.Labels).To(HaveKeyWithValue("app", "test-agent-both-meta")) + + // Pod template annotations: both common and pod annotations present + Expect(deployment.Spec.Template.Annotations).To(HaveKeyWithValue("common-annotation", "common-value")) + Expect(deployment.Spec.Template.Annotations).To(HaveKeyWithValue("pod-annotation", "pod-value")) + }) + }) + + Describe("Selector labels are never overridden", func() { + It("should not allow commonMetadata or podMetadata to override selector labels", func() { + agent := &runtimev1alpha1.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-agent-selector-override", + Namespace: "default", + }, + Spec: runtimev1alpha1.AgentSpec{ + Framework: "google-adk", + Image: "image:tag", + Protocols: []runtimev1alpha1.AgentProtocol{ + {Type: runtimev1alpha1.A2AProtocol, Port: 8000, Path: "/"}, + }, + CommonMetadata: &runtimev1alpha1.EmbeddedMetadata{ + Labels: map[string]string{ + "app": "user-override-attempt", + }, + }, + PodMetadata: &runtimev1alpha1.EmbeddedMetadata{ + Labels: map[string]string{ + "app": "pod-override-attempt", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, agent)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: "test-agent-selector-override", Namespace: "default"}, + }) + Expect(err).NotTo(HaveOccurred()) + + deployment := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-agent-selector-override", Namespace: "default"}, deployment)).To(Succeed()) + + // Selector label "app" must still match the agent name + Expect(deployment.Spec.Template.Labels).To(HaveKeyWithValue("app", "test-agent-selector-override")) + Expect(deployment.Spec.Selector.MatchLabels).To(HaveKeyWithValue("app", "test-agent-selector-override")) + }) + }) +}) + +var _ = Describe("ToolServer Metadata", func() { + ctx := context.Background() + var reconciler *ToolServerReconciler + + BeforeEach(func() { + reconciler = &ToolServerReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + }) + + AfterEach(func() { + toolServerList := &runtimev1alpha1.ToolServerList{} + Expect(k8sClient.List(ctx, toolServerList)).To(Succeed()) + for i := range toolServerList.Items { + _ = k8sClient.Delete(ctx, &toolServerList.Items[i]) + } + }) + + Describe("CommonMetadata", func() { + It("should apply commonMetadata labels and annotations to Deployment and Service", func() { + toolServer := &runtimev1alpha1.ToolServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ts-common-meta", + Namespace: "default", + }, + Spec: runtimev1alpha1.ToolServerSpec{ + Protocol: "mcp", + TransportType: "http", + Image: "image:tag", + Port: 8080, + CommonMetadata: &runtimev1alpha1.EmbeddedMetadata{ + Labels: map[string]string{ + "team": "platform", + "environment": "test", + }, + Annotations: map[string]string{ + "owner": "platform-team", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, toolServer)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: "test-ts-common-meta", Namespace: "default"}, + }) + Expect(err).NotTo(HaveOccurred()) + + // Verify Deployment has commonMetadata labels and annotations + deployment := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-ts-common-meta", Namespace: "default"}, deployment)).To(Succeed()) + Expect(deployment.Labels).To(HaveKeyWithValue("team", "platform")) + Expect(deployment.Labels).To(HaveKeyWithValue("environment", "test")) + Expect(deployment.Annotations).To(HaveKeyWithValue("owner", "platform-team")) + + // Verify pod template has commonMetadata labels and annotations + Expect(deployment.Spec.Template.Labels).To(HaveKeyWithValue("team", "platform")) + Expect(deployment.Spec.Template.Labels).To(HaveKeyWithValue("environment", "test")) + Expect(deployment.Spec.Template.Labels).To(HaveKeyWithValue("app", "test-ts-common-meta")) + Expect(deployment.Spec.Template.Annotations).To(HaveKeyWithValue("owner", "platform-team")) + + // Verify Service has commonMetadata labels and annotations + service := &corev1.Service{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-ts-common-meta", Namespace: "default"}, service)).To(Succeed()) + Expect(service.Labels).To(HaveKeyWithValue("team", "platform")) + Expect(service.Labels).To(HaveKeyWithValue("environment", "test")) + Expect(service.Annotations).To(HaveKeyWithValue("owner", "platform-team")) + }) + }) + + Describe("PodMetadata", func() { + It("should apply podMetadata labels and annotations only to the pod template", func() { + toolServer := &runtimev1alpha1.ToolServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ts-pod-meta", + Namespace: "default", + }, + Spec: runtimev1alpha1.ToolServerSpec{ + Protocol: "mcp", + TransportType: "http", + Image: "image:tag", + Port: 8080, + PodMetadata: &runtimev1alpha1.EmbeddedMetadata{ + Labels: map[string]string{ + "pod-label": "pod-value", + }, + Annotations: map[string]string{ + "pod-annotation": "pod-annotation-value", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, toolServer)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: "test-ts-pod-meta", Namespace: "default"}, + }) + Expect(err).NotTo(HaveOccurred()) + + deployment := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-ts-pod-meta", Namespace: "default"}, deployment)).To(Succeed()) + + // Deployment itself should NOT have podMetadata labels + Expect(deployment.Labels).NotTo(HaveKey("pod-label")) + Expect(deployment.Annotations).To(BeNil()) + + // Pod template should have podMetadata labels and annotations + Expect(deployment.Spec.Template.Labels).To(HaveKeyWithValue("pod-label", "pod-value")) + Expect(deployment.Spec.Template.Annotations).To(HaveKeyWithValue("pod-annotation", "pod-annotation-value")) + Expect(deployment.Spec.Template.Labels).To(HaveKeyWithValue("app", "test-ts-pod-meta")) + + // Service should NOT have podMetadata labels + service := &corev1.Service{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-ts-pod-meta", Namespace: "default"}, service)).To(Succeed()) + Expect(service.Labels).NotTo(HaveKey("pod-label")) + }) + }) + + Describe("No metadata", func() { + It("should work without any metadata configured", func() { + toolServer := &runtimev1alpha1.ToolServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ts-no-meta", + Namespace: "default", + }, + Spec: runtimev1alpha1.ToolServerSpec{ + Protocol: "mcp", + TransportType: "http", + Image: "image:tag", + Port: 8080, + }, + } + Expect(k8sClient.Create(ctx, toolServer)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: "test-ts-no-meta", Namespace: "default"}, + }) + Expect(err).NotTo(HaveOccurred()) + + deployment := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-ts-no-meta", Namespace: "default"}, deployment)).To(Succeed()) + + // Pod template annotations should be nil when no metadata is specified + Expect(deployment.Spec.Template.Annotations).To(BeNil()) + + // Selector labels must be present + Expect(deployment.Spec.Template.Labels).To(HaveKeyWithValue("app", "test-ts-no-meta")) + + // List resources to confirm cleanup + _ = k8sClient.List(ctx, &runtimev1alpha1.ToolServerList{}, client.InNamespace("default")) + }) + }) +}) diff --git a/internal/controller/agent_reconciler.go b/internal/controller/agent_reconciler.go index fd066f4..e31146e 100644 --- a/internal/controller/agent_reconciler.go +++ b/internal/controller/agent_reconciler.go @@ -209,8 +209,12 @@ func (r *AgentReconciler) ensureDeployment(ctx context.Context, agent *runtimev1 } } - // Set selector labels for the pod template respectively - deployment.Spec.Template.Labels = selectorLabels + // Build pod template labels: user commonMetadata + user podMetadata, then enforce selector labels + podTemplateLabels, podTemplateAnnotations := buildPodTemplateMetadata( + selectorLabels, agent.Spec.CommonMetadata, agent.Spec.PodMetadata, + ) + deployment.Spec.Template.Labels = podTemplateLabels + deployment.Spec.Template.Annotations = podTemplateAnnotations // Set replicas if deployment.Spec.Replicas == nil { @@ -220,10 +224,11 @@ func (r *AgentReconciler) ensureDeployment(ctx context.Context, agent *runtimev1 *deployment.Spec.Replicas = *agent.Spec.Replicas } - // Merge managed labels (preserving unmanaged labels) + // Merge managed labels and user commonMetadata labels (preserving unmanaged labels) if deployment.Labels == nil { deployment.Labels = make(map[string]string) } + applyCommonMetadataToObjectMeta(&deployment.ObjectMeta, agent.Spec.CommonMetadata) maps.Copy(deployment.Labels, managedLabels) // Update agent container fields @@ -316,10 +321,11 @@ func (r *AgentReconciler) ensureService(ctx context.Context, agent *runtimev1alp }) } - // Merge managed labels (preserving unmanaged labels) + // Merge managed labels and user commonMetadata labels (preserving unmanaged labels) if service.Labels == nil { service.Labels = make(map[string]string) } + applyCommonMetadataToObjectMeta(&service.ObjectMeta, agent.Spec.CommonMetadata) maps.Copy(service.Labels, managedLabels) // Update service spec diff --git a/internal/controller/toolserver_reconciler.go b/internal/controller/toolserver_reconciler.go index 10850ec..fcf2359 100644 --- a/internal/controller/toolserver_reconciler.go +++ b/internal/controller/toolserver_reconciler.go @@ -185,8 +185,12 @@ func (r *ToolServerReconciler) ensureDeployment(ctx context.Context, toolServer } } - // Set selector labels for the pod template - deployment.Spec.Template.Labels = selectorLabels + // Build pod template labels: user commonMetadata + user podMetadata, then enforce selector labels + podTemplateLabels, podTemplateAnnotations := buildPodTemplateMetadata( + selectorLabels, toolServer.Spec.CommonMetadata, toolServer.Spec.PodMetadata, + ) + deployment.Spec.Template.Labels = podTemplateLabels + deployment.Spec.Template.Annotations = podTemplateAnnotations // Set/update replicas if deployment.Spec.Replicas == nil { @@ -198,10 +202,11 @@ func (r *ToolServerReconciler) ensureDeployment(ctx context.Context, toolServer *deployment.Spec.Replicas = 1 } - // Merge managed labels (preserving unmanaged labels) + // Merge managed labels and user commonMetadata labels (preserving unmanaged labels) if deployment.Labels == nil { deployment.Labels = make(map[string]string) } + applyCommonMetadataToObjectMeta(&deployment.ObjectMeta, toolServer.Spec.CommonMetadata) maps.Copy(deployment.Labels, managedLabels) // Update toolserver container fields @@ -273,6 +278,7 @@ func (r *ToolServerReconciler) ensureService(ctx context.Context, toolServer *ru if service.Labels == nil { service.Labels = make(map[string]string) } + applyCommonMetadataToObjectMeta(&service.ObjectMeta, toolServer.Spec.CommonMetadata) maps.Copy(service.Labels, managedLabels) // Update service spec diff --git a/internal/controller/util.go b/internal/controller/util.go index 790545a..de10b22 100644 --- a/internal/controller/util.go +++ b/internal/controller/util.go @@ -1,7 +1,11 @@ package controller import ( + "maps" + + runtimev1alpha1 "github.com/agentic-layer/agent-runtime-operator/api/v1alpha1" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // findEnvVar finds an environment variable by name in a slice of EnvVars @@ -32,3 +36,55 @@ func getNamespaceWithDefault(objRef *corev1.ObjectReference, defaultNamespace st } return defaultNamespace } + +// applyCommonMetadataToObjectMeta merges commonMetadata labels and annotations into the +// provided ObjectMeta. Managed labels should be applied after this call so they take precedence. +func applyCommonMetadataToObjectMeta(obj *metav1.ObjectMeta, commonMetadata *runtimev1alpha1.EmbeddedMetadata) { + if commonMetadata == nil { + return + } + if len(commonMetadata.Labels) > 0 { + if obj.Labels == nil { + obj.Labels = make(map[string]string) + } + maps.Copy(obj.Labels, commonMetadata.Labels) + } + if len(commonMetadata.Annotations) > 0 { + if obj.Annotations == nil { + obj.Annotations = make(map[string]string) + } + maps.Copy(obj.Annotations, commonMetadata.Annotations) + } +} + +// buildPodTemplateMetadata builds the labels and annotations for a pod template. +// commonMetadata and podMetadata are merged, with podMetadata taking precedence. +// selectorLabels are always enforced last to ensure the pod selector is never overridden. +// Returns nil annotations if no annotations are configured. +func buildPodTemplateMetadata( + selectorLabels map[string]string, + commonMetadata, podMetadata *runtimev1alpha1.EmbeddedMetadata, +) (labels, annotations map[string]string) { + podLabels := map[string]string{} + if commonMetadata != nil { + maps.Copy(podLabels, commonMetadata.Labels) + } + if podMetadata != nil { + maps.Copy(podLabels, podMetadata.Labels) + } + // Selector labels always take precedence + maps.Copy(podLabels, selectorLabels) + + podAnnotations := map[string]string{} + if commonMetadata != nil { + maps.Copy(podAnnotations, commonMetadata.Annotations) + } + if podMetadata != nil { + maps.Copy(podAnnotations, podMetadata.Annotations) + } + + if len(podAnnotations) == 0 { + return podLabels, nil + } + return podLabels, podAnnotations +}