From 81fcc4dcfa51ea50a67fc9084854d7a3d5b7348d Mon Sep 17 00:00:00 2001 From: Mike Ng Date: Wed, 22 Oct 2025 13:21:15 -0400 Subject: [PATCH] feat: Support Argo CD Agent's Agent Components in operator Signed-off-by: Mike Ng --- Makefile | 2 +- api/v1alpha1/argocd_types.go | 76 +++ api/v1alpha1/zz_generated.deepcopy.go | 107 ++++ api/v1beta1/argocd_types.go | 76 +++ api/v1beta1/zz_generated.deepcopy.go | 107 ++++ ...argocd-operator.clusterserviceversion.yaml | 2 +- bundle/manifests/argoproj.io_argocds.yaml | 404 ++++++++++++ common/defaults.go | 5 +- config/crd/bases/argoproj.io_argocds.yaml | 404 ++++++++++++ controllers/argocd/networkpolicies.go | 10 + controllers/argocd/util.go | 79 ++- controllers/argocdagent/agent/deployment.go | 493 ++++++++++++++ .../argocdagent/agent/deployment_test.go | 537 ++++++++++++++++ controllers/argocdagent/agent/role.go | 232 +++++++ controllers/argocdagent/agent/role_test.go | 422 ++++++++++++ controllers/argocdagent/agent/rolebinding.go | 194 ++++++ .../argocdagent/agent/rolebinding_test.go | 601 ++++++++++++++++++ controllers/argocdagent/agent/service.go | 211 ++++++ .../argocdagent/agent/service_account.go | 104 +++ .../argocdagent/agent/service_account_test.go | 261 ++++++++ controllers/argocdagent/agent/service_test.go | 522 +++++++++++++++ controllers/argocdagent/docs/quickstart.md | 21 +- .../argocdagent/example/argocd-agent.yaml | 52 ++ .../{argocd.yaml => argocd-principal.yaml} | 4 +- ...perator.v0.17.0.clusterserviceversion.yaml | 2 +- .../0.17.0/argoproj.io_argocds.yaml | 404 ++++++++++++ .../1-052_validate_argocd_agent_agent_test.go | 448 +++++++++++++ 27 files changed, 5760 insertions(+), 20 deletions(-) create mode 100644 controllers/argocdagent/agent/deployment.go create mode 100644 controllers/argocdagent/agent/deployment_test.go create mode 100644 controllers/argocdagent/agent/role.go create mode 100644 controllers/argocdagent/agent/role_test.go create mode 100644 controllers/argocdagent/agent/rolebinding.go create mode 100644 controllers/argocdagent/agent/rolebinding_test.go create mode 100644 controllers/argocdagent/agent/service.go create mode 100644 controllers/argocdagent/agent/service_account.go create mode 100644 controllers/argocdagent/agent/service_account_test.go create mode 100644 controllers/argocdagent/agent/service_test.go create mode 100644 controllers/argocdagent/example/argocd-agent.yaml rename controllers/argocdagent/example/{argocd.yaml => argocd-principal.yaml} (98%) create mode 100644 tests/ginkgo/sequential/1-052_validate_argocd_agent_agent_test.go diff --git a/Makefile b/Makefile index 8411bb9eb..4a0237b37 100644 --- a/Makefile +++ b/Makefile @@ -177,7 +177,7 @@ e2e: ## Run operator e2e tests start-e2e: - ARGOCD_CLUSTER_CONFIG_NAMESPACES="argocd-e2e-cluster-config, argocd-test-impersonation-1-046, argocd-agent-principal-1-051" make run + ARGOCD_CLUSTER_CONFIG_NAMESPACES="argocd-e2e-cluster-config, argocd-test-impersonation-1-046, argocd-agent-principal-1-051, argocd-agent-agent-1-052" make run all: test install run e2e ## UnitTest, Run the operator locally and execute e2e tests. diff --git a/api/v1alpha1/argocd_types.go b/api/v1alpha1/argocd_types.go index bd9777e87..2d09df418 100644 --- a/api/v1alpha1/argocd_types.go +++ b/api/v1alpha1/argocd_types.go @@ -1020,6 +1020,9 @@ type ArgoCDAgentSpec struct { // Principal defines configurations for the Principal component of Argo CD Agent. Principal *PrincipalSpec `json:"principal,omitempty"` + + // Agent defines configurations for the Agent component of Argo CD Agent. + Agent *AgentSpec `json:"agent,omitempty"` } type PrincipalSpec struct { @@ -1149,6 +1152,79 @@ func (a *PrincipalSpec) IsEnabled() bool { return a.Enabled != nil && *a.Enabled } +type AgentSpec struct { + + // Enabled is the flag to enable the Agent component during Argo CD installation. (optional, default `false`) + Enabled *bool `json:"enabled,omitempty"` + + // Client defines the client options for the Agent component. + Client *AgentClientSpec `json:"client,omitempty"` + + // TLS defines the TLS options for the Agent component. + TLS *AgentTLSSpec `json:"tls,omitempty"` + + // Redis defines the Redis options for the Agent component. + Redis *AgentRedisSpec `json:"redis,omitempty"` +} + +type AgentClientSpec struct { + + // PrincipalServerAddress is the remote address of the principal server to connect to. + PrincipalServerAddress string `json:"principalServerAddress,omitempty"` + + // PrincipalServerPort is the remote port of the principal server to connect to. + PrincipalServerPort string `json:"principalServerPort,omitempty"` + + // Creds is the credential identifier for the agent authentication + Creds string `json:"creds,omitempty"` + + // Mode is the operational mode for the agent (managed or autonomous) + Mode string `json:"mode,omitempty"` + + // EnableWebSocket is the flag to enable WebSocket for event streaming + EnableWebSocket *bool `json:"enableWebSocket,omitempty"` + + // EnableCompression is the flag to enable compression while sending data between Principal and Agent using gRPC + EnableCompression *bool `json:"enableCompression,omitempty"` + + // LogLevel refers to the log level used by the Agent component. + LogLevel string `json:"logLevel,omitempty"` + + // LogFormat refers to the log format used by the Agent component. + LogFormat string `json:"logFormat,omitempty"` + + // KeepAliveInterval is the interval for keep-alive pings to the principal + KeepAliveInterval string `json:"keepAliveInterval,omitempty"` + + // Image is the name of Argo CD Agent image + Image string `json:"image,omitempty"` + + // Env lets you specify environment for agent pods + Env []corev1.EnvVar `json:"env,omitempty"` +} + +type AgentTLSSpec struct { + + // SecretName is the name of the secret containing the agent client TLS certificate + SecretName string `json:"secretName,omitempty"` + + // RootCASecretName is the name of the secret containing the root CA certificate + RootCASecretName string `json:"rootCASecretName,omitempty"` + + // Insecure is the flag to skip TLS certificate validation when connecting to the principal (insecure, for development only) + Insecure *bool `json:"insecure,omitempty"` +} + +type AgentRedisSpec struct { + + // ServerAddress is the address of the Redis server to be used by the PrincAgentipal component. + ServerAddress string `json:"serverAddress,omitempty"` +} + +func (a *AgentSpec) IsEnabled() bool { + return a.Enabled != nil && *a.Enabled +} + // IsDeletionFinalizerPresent checks if the instance has deletion finalizer func (argocd *ArgoCD) IsDeletionFinalizerPresent() bool { for _, finalizer := range argocd.GetFinalizers() { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index d8f149cd3..7437fb76d 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -29,6 +29,108 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentClientSpec) DeepCopyInto(out *AgentClientSpec) { + *out = *in + if in.EnableWebSocket != nil { + in, out := &in.EnableWebSocket, &out.EnableWebSocket + *out = new(bool) + **out = **in + } + if in.EnableCompression != nil { + in, out := &in.EnableCompression, &out.EnableCompression + *out = new(bool) + **out = **in + } + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = make([]v1.EnvVar, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentClientSpec. +func (in *AgentClientSpec) DeepCopy() *AgentClientSpec { + if in == nil { + return nil + } + out := new(AgentClientSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentRedisSpec) DeepCopyInto(out *AgentRedisSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentRedisSpec. +func (in *AgentRedisSpec) DeepCopy() *AgentRedisSpec { + if in == nil { + return nil + } + out := new(AgentRedisSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentSpec) DeepCopyInto(out *AgentSpec) { + *out = *in + if in.Enabled != nil { + in, out := &in.Enabled, &out.Enabled + *out = new(bool) + **out = **in + } + if in.Client != nil { + in, out := &in.Client, &out.Client + *out = new(AgentClientSpec) + (*in).DeepCopyInto(*out) + } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(AgentTLSSpec) + (*in).DeepCopyInto(*out) + } + if in.Redis != nil { + in, out := &in.Redis, &out.Redis + *out = new(AgentRedisSpec) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentSpec. +func (in *AgentSpec) DeepCopy() *AgentSpec { + if in == nil { + return nil + } + out := new(AgentSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentTLSSpec) DeepCopyInto(out *AgentTLSSpec) { + *out = *in + if in.Insecure != nil { + in, out := &in.Insecure, &out.Insecure + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentTLSSpec. +func (in *AgentTLSSpec) DeepCopy() *AgentTLSSpec { + if in == nil { + return nil + } + out := new(AgentTLSSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ArgoCD) DeepCopyInto(out *ArgoCD) { *out = *in @@ -99,6 +201,11 @@ func (in *ArgoCDAgentSpec) DeepCopyInto(out *ArgoCDAgentSpec) { *out = new(PrincipalSpec) (*in).DeepCopyInto(*out) } + if in.Agent != nil { + in, out := &in.Agent, &out.Agent + *out = new(AgentSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ArgoCDAgentSpec. diff --git a/api/v1beta1/argocd_types.go b/api/v1beta1/argocd_types.go index e7d16c0c1..1413e27e0 100644 --- a/api/v1beta1/argocd_types.go +++ b/api/v1beta1/argocd_types.go @@ -1178,6 +1178,9 @@ type ArgoCDAgentSpec struct { // Principal defines configurations for the Principal component of Argo CD Agent. Principal *PrincipalSpec `json:"principal,omitempty"` + + // Agent defines configurations for the Agent component of Argo CD Agent. + Agent *AgentSpec `json:"agent,omitempty"` } type PrincipalSpec struct { @@ -1308,6 +1311,79 @@ func (a *PrincipalSpec) IsEnabled() bool { return a.Enabled != nil && *a.Enabled } +type AgentSpec struct { + + // Enabled is the flag to enable the Agent component during Argo CD installation. (optional, default `false`) + Enabled *bool `json:"enabled,omitempty"` + + // Client defines the client options for the Agent component. + Client *AgentClientSpec `json:"client,omitempty"` + + // Redis defines the Redis options for the Agent component. + Redis *AgentRedisSpec `json:"redis,omitempty"` + + // TLS defines the TLS options for the Agent component. + TLS *AgentTLSSpec `json:"tls,omitempty"` +} + +type AgentClientSpec struct { + + // PrincipalServerAddress is the remote address of the principal server to connect to. + PrincipalServerAddress string `json:"principalServerAddress,omitempty"` + + // PrincipalServerPort is the remote port of the principal server to connect to. + PrincipalServerPort string `json:"principalServerPort,omitempty"` + + // Creds is the credential identifier for the agent authentication + Creds string `json:"creds,omitempty"` + + // Mode is the operational mode for the agent (managed or autonomous) + Mode string `json:"mode,omitempty"` + + // EnableWebSocket is the flag to enable WebSocket for event streaming + EnableWebSocket *bool `json:"enableWebSocket,omitempty"` + + // EnableCompression is the flag to enable compression while sending data between Principal and Agent using gRPC + EnableCompression *bool `json:"enableCompression,omitempty"` + + // LogLevel refers to the log level used by the Agent component. + LogLevel string `json:"logLevel,omitempty"` + + // LogFormat refers to the log format used by the Agent component. + LogFormat string `json:"logFormat,omitempty"` + + // KeepAliveInterval is the interval for keep-alive pings to the principal + KeepAliveInterval string `json:"keepAliveInterval,omitempty"` + + // Image is the name of Argo CD Agent image + Image string `json:"image,omitempty"` + + // Env lets you specify environment for agent pods + Env []corev1.EnvVar `json:"env,omitempty"` +} + +type AgentRedisSpec struct { + + // ServerAddress is the address of the Redis server to be used by the PrincAgentipal component. + ServerAddress string `json:"serverAddress,omitempty"` +} + +type AgentTLSSpec struct { + + // SecretName is the name of the secret containing the agent client TLS certificate + SecretName string `json:"secretName,omitempty"` + + // RootCASecretName is the name of the secret containing the root CA certificate + RootCASecretName string `json:"rootCASecretName,omitempty"` + + // Insecure is the flag to skip TLS certificate validation when connecting to the principal (insecure, for development only) + Insecure *bool `json:"insecure,omitempty"` +} + +func (a *AgentSpec) IsEnabled() bool { + return a.Enabled != nil && *a.Enabled +} + // IsDeletionFinalizerPresent checks if the instance has deletion finalizer func (argocd *ArgoCD) IsDeletionFinalizerPresent() bool { for _, finalizer := range argocd.GetFinalizers() { diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 267da9f53..29f2bd26e 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -29,6 +29,108 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentClientSpec) DeepCopyInto(out *AgentClientSpec) { + *out = *in + if in.EnableWebSocket != nil { + in, out := &in.EnableWebSocket, &out.EnableWebSocket + *out = new(bool) + **out = **in + } + if in.EnableCompression != nil { + in, out := &in.EnableCompression, &out.EnableCompression + *out = new(bool) + **out = **in + } + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = make([]v1.EnvVar, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentClientSpec. +func (in *AgentClientSpec) DeepCopy() *AgentClientSpec { + if in == nil { + return nil + } + out := new(AgentClientSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentRedisSpec) DeepCopyInto(out *AgentRedisSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentRedisSpec. +func (in *AgentRedisSpec) DeepCopy() *AgentRedisSpec { + if in == nil { + return nil + } + out := new(AgentRedisSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentSpec) DeepCopyInto(out *AgentSpec) { + *out = *in + if in.Enabled != nil { + in, out := &in.Enabled, &out.Enabled + *out = new(bool) + **out = **in + } + if in.Client != nil { + in, out := &in.Client, &out.Client + *out = new(AgentClientSpec) + (*in).DeepCopyInto(*out) + } + if in.Redis != nil { + in, out := &in.Redis, &out.Redis + *out = new(AgentRedisSpec) + **out = **in + } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(AgentTLSSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentSpec. +func (in *AgentSpec) DeepCopy() *AgentSpec { + if in == nil { + return nil + } + out := new(AgentSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentTLSSpec) DeepCopyInto(out *AgentTLSSpec) { + *out = *in + if in.Insecure != nil { + in, out := &in.Insecure, &out.Insecure + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentTLSSpec. +func (in *AgentTLSSpec) DeepCopy() *AgentTLSSpec { + if in == nil { + return nil + } + out := new(AgentTLSSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ArgoCD) DeepCopyInto(out *ArgoCD) { *out = *in @@ -99,6 +201,11 @@ func (in *ArgoCDAgentSpec) DeepCopyInto(out *ArgoCDAgentSpec) { *out = new(PrincipalSpec) (*in).DeepCopyInto(*out) } + if in.Agent != nil { + in, out := &in.Agent, &out.Agent + *out = new(AgentSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ArgoCDAgentSpec. diff --git a/bundle/manifests/argocd-operator.clusterserviceversion.yaml b/bundle/manifests/argocd-operator.clusterserviceversion.yaml index 2799f67ce..dace928a6 100644 --- a/bundle/manifests/argocd-operator.clusterserviceversion.yaml +++ b/bundle/manifests/argocd-operator.clusterserviceversion.yaml @@ -257,7 +257,7 @@ metadata: capabilities: Deep Insights categories: Integration & Delivery certified: "false" - createdAt: "2025-11-03T14:36:18Z" + createdAt: "2025-11-04T15:44:28Z" description: Argo CD is a declarative, GitOps continuous delivery tool for Kubernetes. operators.operatorframework.io/builder: operator-sdk-v1.35.0 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/bundle/manifests/argoproj.io_argocds.yaml b/bundle/manifests/argoproj.io_argocds.yaml index 8ec45d7c0..5c5744fb5 100644 --- a/bundle/manifests/argoproj.io_argocds.yaml +++ b/bundle/manifests/argoproj.io_argocds.yaml @@ -450,6 +450,208 @@ spec: description: ArgoCDAgent defines configurations for the ArgoCD Agent component. properties: + agent: + description: Agent defines configurations for the Agent component + of Argo CD Agent. + properties: + client: + description: Client defines the client options for the Agent + component. + properties: + creds: + description: Creds is the credential identifier for the + agent authentication + type: string + enableCompression: + description: EnableCompression is the flag to enable compression + while sending data between Principal and Agent using + gRPC + type: boolean + enableWebSocket: + description: EnableWebSocket is the flag to enable WebSocket + for event streaming + type: boolean + env: + description: Env lets you specify environment for agent + pods + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + image: + description: Image is the name of Argo CD Agent image + type: string + keepAliveInterval: + description: KeepAliveInterval is the interval for keep-alive + pings to the principal + type: string + logFormat: + description: LogFormat refers to the log format used by + the Agent component. + type: string + logLevel: + description: LogLevel refers to the log level used by + the Agent component. + type: string + mode: + description: Mode is the operational mode for the agent + (managed or autonomous) + type: string + principalServerAddress: + description: PrincipalServerAddress is the remote address + of the principal server to connect to. + type: string + principalServerPort: + description: PrincipalServerPort is the remote port of + the principal server to connect to. + type: string + type: object + enabled: + description: Enabled is the flag to enable the Agent component + during Argo CD installation. (optional, default `false`) + type: boolean + redis: + description: Redis defines the Redis options for the Agent + component. + properties: + serverAddress: + description: ServerAddress is the address of the Redis + server to be used by the PrincAgentipal component. + type: string + type: object + tls: + description: TLS defines the TLS options for the Agent component. + properties: + insecure: + description: Insecure is the flag to skip TLS certificate + validation when connecting to the principal (insecure, + for development only) + type: boolean + rootCASecretName: + description: RootCASecretName is the name of the secret + containing the root CA certificate + type: string + secretName: + description: SecretName is the name of the secret containing + the agent client TLS certificate + type: string + type: object + type: object principal: description: Principal defines configurations for the Principal component of Argo CD Agent. @@ -10440,6 +10642,208 @@ spec: description: ArgoCDAgent defines configurations for the ArgoCD Agent component. properties: + agent: + description: Agent defines configurations for the Agent component + of Argo CD Agent. + properties: + client: + description: Client defines the client options for the Agent + component. + properties: + creds: + description: Creds is the credential identifier for the + agent authentication + type: string + enableCompression: + description: EnableCompression is the flag to enable compression + while sending data between Principal and Agent using + gRPC + type: boolean + enableWebSocket: + description: EnableWebSocket is the flag to enable WebSocket + for event streaming + type: boolean + env: + description: Env lets you specify environment for agent + pods + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + image: + description: Image is the name of Argo CD Agent image + type: string + keepAliveInterval: + description: KeepAliveInterval is the interval for keep-alive + pings to the principal + type: string + logFormat: + description: LogFormat refers to the log format used by + the Agent component. + type: string + logLevel: + description: LogLevel refers to the log level used by + the Agent component. + type: string + mode: + description: Mode is the operational mode for the agent + (managed or autonomous) + type: string + principalServerAddress: + description: PrincipalServerAddress is the remote address + of the principal server to connect to. + type: string + principalServerPort: + description: PrincipalServerPort is the remote port of + the principal server to connect to. + type: string + type: object + enabled: + description: Enabled is the flag to enable the Agent component + during Argo CD installation. (optional, default `false`) + type: boolean + redis: + description: Redis defines the Redis options for the Agent + component. + properties: + serverAddress: + description: ServerAddress is the address of the Redis + server to be used by the PrincAgentipal component. + type: string + type: object + tls: + description: TLS defines the TLS options for the Agent component. + properties: + insecure: + description: Insecure is the flag to skip TLS certificate + validation when connecting to the principal (insecure, + for development only) + type: boolean + rootCASecretName: + description: RootCASecretName is the name of the secret + containing the root CA certificate + type: string + secretName: + description: SecretName is the name of the secret containing + the agent client TLS certificate + type: string + type: object + type: object principal: description: Principal defines configurations for the Principal component of Argo CD Agent. diff --git a/common/defaults.go b/common/defaults.go index cd59addc8..e18e4d71c 100644 --- a/common/defaults.go +++ b/common/defaults.go @@ -287,9 +287,12 @@ vs-ssh.visualstudio.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7Hr1oTWqNqOlzGJOf // ArgoCDCmdParamsConfigMapName is the upstream hard-coded ArgoCD command params ConfigMap name. ArgoCDCmdParamsConfigMapName = "argocd-cmd-params-cm" - // ArgoCDAgentPrincipalDefaultImageName is the default image name for the ArgoCD agent principal. + // ArgoCDAgentPrincipalDefaultImageName is the default image name for the ArgoCD agent's principal component. ArgoCDAgentPrincipalDefaultImageName = "quay.io/argoprojlabs/argocd-agent:v0.3.2" + // ArgoCDAgentAgentDefaultImageName is the default image name for the ArgoCD agent's agent component. + ArgoCDAgentAgentDefaultImageName = "quay.io/argoprojlabs/argocd-agent:v0.3.2" + // ArgoCDImageUpdaterControllerComponent is the name of the Image Updater controller control plane component ArgoCDImageUpdaterControllerComponent = "argocd-image-updater-controller" diff --git a/config/crd/bases/argoproj.io_argocds.yaml b/config/crd/bases/argoproj.io_argocds.yaml index 0087e88c5..ae6725785 100644 --- a/config/crd/bases/argoproj.io_argocds.yaml +++ b/config/crd/bases/argoproj.io_argocds.yaml @@ -439,6 +439,208 @@ spec: description: ArgoCDAgent defines configurations for the ArgoCD Agent component. properties: + agent: + description: Agent defines configurations for the Agent component + of Argo CD Agent. + properties: + client: + description: Client defines the client options for the Agent + component. + properties: + creds: + description: Creds is the credential identifier for the + agent authentication + type: string + enableCompression: + description: EnableCompression is the flag to enable compression + while sending data between Principal and Agent using + gRPC + type: boolean + enableWebSocket: + description: EnableWebSocket is the flag to enable WebSocket + for event streaming + type: boolean + env: + description: Env lets you specify environment for agent + pods + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + image: + description: Image is the name of Argo CD Agent image + type: string + keepAliveInterval: + description: KeepAliveInterval is the interval for keep-alive + pings to the principal + type: string + logFormat: + description: LogFormat refers to the log format used by + the Agent component. + type: string + logLevel: + description: LogLevel refers to the log level used by + the Agent component. + type: string + mode: + description: Mode is the operational mode for the agent + (managed or autonomous) + type: string + principalServerAddress: + description: PrincipalServerAddress is the remote address + of the principal server to connect to. + type: string + principalServerPort: + description: PrincipalServerPort is the remote port of + the principal server to connect to. + type: string + type: object + enabled: + description: Enabled is the flag to enable the Agent component + during Argo CD installation. (optional, default `false`) + type: boolean + redis: + description: Redis defines the Redis options for the Agent + component. + properties: + serverAddress: + description: ServerAddress is the address of the Redis + server to be used by the PrincAgentipal component. + type: string + type: object + tls: + description: TLS defines the TLS options for the Agent component. + properties: + insecure: + description: Insecure is the flag to skip TLS certificate + validation when connecting to the principal (insecure, + for development only) + type: boolean + rootCASecretName: + description: RootCASecretName is the name of the secret + containing the root CA certificate + type: string + secretName: + description: SecretName is the name of the secret containing + the agent client TLS certificate + type: string + type: object + type: object principal: description: Principal defines configurations for the Principal component of Argo CD Agent. @@ -10429,6 +10631,208 @@ spec: description: ArgoCDAgent defines configurations for the ArgoCD Agent component. properties: + agent: + description: Agent defines configurations for the Agent component + of Argo CD Agent. + properties: + client: + description: Client defines the client options for the Agent + component. + properties: + creds: + description: Creds is the credential identifier for the + agent authentication + type: string + enableCompression: + description: EnableCompression is the flag to enable compression + while sending data between Principal and Agent using + gRPC + type: boolean + enableWebSocket: + description: EnableWebSocket is the flag to enable WebSocket + for event streaming + type: boolean + env: + description: Env lets you specify environment for agent + pods + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + image: + description: Image is the name of Argo CD Agent image + type: string + keepAliveInterval: + description: KeepAliveInterval is the interval for keep-alive + pings to the principal + type: string + logFormat: + description: LogFormat refers to the log format used by + the Agent component. + type: string + logLevel: + description: LogLevel refers to the log level used by + the Agent component. + type: string + mode: + description: Mode is the operational mode for the agent + (managed or autonomous) + type: string + principalServerAddress: + description: PrincipalServerAddress is the remote address + of the principal server to connect to. + type: string + principalServerPort: + description: PrincipalServerPort is the remote port of + the principal server to connect to. + type: string + type: object + enabled: + description: Enabled is the flag to enable the Agent component + during Argo CD installation. (optional, default `false`) + type: boolean + redis: + description: Redis defines the Redis options for the Agent + component. + properties: + serverAddress: + description: ServerAddress is the address of the Redis + server to be used by the PrincAgentipal component. + type: string + type: object + tls: + description: TLS defines the TLS options for the Agent component. + properties: + insecure: + description: Insecure is the flag to skip TLS certificate + validation when connecting to the principal (insecure, + for development only) + type: boolean + rootCASecretName: + description: RootCASecretName is the name of the secret + containing the root CA certificate + type: string + secretName: + description: SecretName is the name of the secret containing + the agent client TLS certificate + type: string + type: object + type: object principal: description: Principal defines configurations for the Principal component of Argo CD Agent. diff --git a/controllers/argocd/networkpolicies.go b/controllers/argocd/networkpolicies.go index 0ba1000cf..2c91662fb 100644 --- a/controllers/argocd/networkpolicies.go +++ b/controllers/argocd/networkpolicies.go @@ -107,6 +107,16 @@ func (r *ReconcileArgoCD) ReconcileRedisNetworkPolicy(cr *argoproj.ArgoCD) error }) } + if cr.Spec.ArgoCDAgent != nil && cr.Spec.ArgoCDAgent.Agent != nil && cr.Spec.ArgoCDAgent.Agent.IsEnabled() { + networkPolicy.Spec.Ingress[0].From = append(networkPolicy.Spec.Ingress[0].From, networkingv1.NetworkPolicyPeer{ + PodSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app.kubernetes.io/name": nameWithSuffix("agent-agent", cr), + }, + }, + }) + } + // Check if the network policy already exists existing := &networkingv1.NetworkPolicy{ ObjectMeta: metav1.ObjectMeta{ diff --git a/controllers/argocd/util.go b/controllers/argocd/util.go index d6c1838c1..4f8d21aa4 100644 --- a/controllers/argocd/util.go +++ b/controllers/argocd/util.go @@ -41,6 +41,7 @@ import ( argoproj "github.com/argoproj-labs/argocd-operator/api/v1beta1" "github.com/argoproj-labs/argocd-operator/common" "github.com/argoproj-labs/argocd-operator/controllers/argocdagent" + "github.com/argoproj-labs/argocd-operator/controllers/argocdagent/agent" "github.com/argoproj-labs/argocd-operator/controllers/argoutil" configv1 "github.com/openshift/api/config/v1" @@ -1811,72 +1812,124 @@ func appendUniqueArgs(cmd []string, extraArgs []string) []string { // reconcileArgoCDAgent will reconcile all ArgoCD Agent resources. func (r *ReconcileArgoCD) reconcileArgoCDAgent(cr *argoproj.ArgoCD) error { - compName := "principal" log.Info("reconciling ArgoCD Agent resources") - log.Info("reconciling ArgoCD Agent service account") + principalEnabled := cr.Spec.ArgoCDAgent != nil && cr.Spec.ArgoCDAgent.Principal != nil && cr.Spec.ArgoCDAgent.Principal.IsEnabled() + agentEnabled := cr.Spec.ArgoCDAgent != nil && cr.Spec.ArgoCDAgent.Agent != nil && cr.Spec.ArgoCDAgent.Agent.IsEnabled() + + if principalEnabled && agentEnabled { + return fmt.Errorf("spec.argoCDAgent.principal and spec.argoCDAgent.agent cannot both be enabled") + } + + log.Info("reconciling ArgoCD Agent's Principal resources") + compName := "principal" var sa *corev1.ServiceAccount var err error + log.Info("reconciling ArgoCD Agent's Principal service account") if sa, err = argocdagent.ReconcilePrincipalServiceAccount(r.Client, compName, cr, r.Scheme); err != nil { return err } - log.Info("reconciling ArgoCD Agent role") + log.Info("reconciling ArgoCD Agent's Principal role") if _, err := argocdagent.ReconcilePrincipalRole(r.Client, compName, cr, r.Scheme); err != nil { return err } - log.Info("reconciling ArgoCD Agent cluster role") + log.Info("reconciling ArgoCD Agent's Principal cluster role") if _, err := argocdagent.ReconcilePrincipalClusterRoles(r.Client, compName, cr, r.Scheme); err != nil { return err } - log.Info("reconciling ArgoCD Agent role binding") + log.Info("reconciling ArgoCD Agent's Principal role binding") if err := argocdagent.ReconcilePrincipalRoleBinding(r.Client, compName, sa, cr, r.Scheme); err != nil { return err } - log.Info("reconciling ArgoCD Agent cluster role binding") + log.Info("reconciling ArgoCD Agent's Principal cluster role binding") if err := argocdagent.ReconcilePrincipalClusterRoleBinding(r.Client, compName, sa, cr, r.Scheme); err != nil { return err } - log.Info("reconciling ArgoCD Agent service") + log.Info("reconciling ArgoCD Agent's Principal service") if err := argocdagent.ReconcilePrincipalService(r.Client, compName, cr, r.Scheme); err != nil { return err } - log.Info("reconciling ArgoCD Agent metrics service") + log.Info("reconciling ArgoCD Agent's Principal metrics service") if err := argocdagent.ReconcilePrincipalMetricsService(r.Client, compName, cr, r.Scheme); err != nil { return err } - log.Info("reconciling ArgoCD Agent redis proxy service") + log.Info("reconciling ArgoCD Agent's Principal redis proxy service") if err := argocdagent.ReconcilePrincipalRedisProxyService(r.Client, compName, cr, r.Scheme); err != nil { return err } - log.Info("reconciling ArgoCD Agent resource proxy service") + log.Info("reconciling ArgoCD Agent's Principal resource proxy service") if err := argocdagent.ReconcilePrincipalResourceProxyService(r.Client, compName, cr, r.Scheme); err != nil { return err } - log.Info("reconciling ArgoCD Agent healthz service") + log.Info("reconciling ArgoCD Agent's Principal healthz service") if err := argocdagent.ReconcilePrincipalHealthzService(r.Client, compName, cr, r.Scheme); err != nil { return err } - log.Info("reconciling ArgoCD Agent route") + log.Info("reconciling ArgoCD Agent's Principal route") if err := argocdagent.ReconcilePrincipalRoute(r.Client, compName, cr, r.Scheme); err != nil { return err } - log.Info("reconciling ArgoCD Agent deployment") + log.Info("reconciling ArgoCD Agent's Principal deployment") if err := argocdagent.ReconcilePrincipalDeployment(r.Client, compName, sa.Name, cr, r.Scheme); err != nil { return err } + log.Info("reconciling ArgoCD Agent's Agent resources") + agentCompName := "agent" + + log.Info("reconciling ArgoCD Agent's Agent service account") + var agentSa *corev1.ServiceAccount + if agentSa, err = agent.ReconcileAgentServiceAccount(r.Client, agentCompName, cr, r.Scheme); err != nil { + return err + } + + log.Info("reconciling ArgoCD Agent's Agent role") + if _, err := agent.ReconcileAgentRole(r.Client, agentCompName, cr, r.Scheme); err != nil { + return err + } + + log.Info("reconciling ArgoCD Agent's Agent cluster role") + if _, err := agent.ReconcileAgentClusterRoles(r.Client, agentCompName, cr, r.Scheme); err != nil { + return err + } + + log.Info("reconciling ArgoCD Agent's Agent role binding") + if err := agent.ReconcileAgentRoleBinding(r.Client, agentCompName, agentSa, cr, r.Scheme); err != nil { + return err + } + + log.Info("reconciling ArgoCD Agent's Agent cluster role binding") + if err := agent.ReconcileAgentClusterRoleBinding(r.Client, agentCompName, agentSa, cr, r.Scheme); err != nil { + return err + } + + log.Info("reconciling ArgoCD Agent's Agent metrics service") + if err := agent.ReconcileAgentMetricsService(r.Client, agentCompName, cr, r.Scheme); err != nil { + return err + } + + log.Info("reconciling ArgoCD Agent's Agent healthz service") + if err := agent.ReconcileAgentHealthzService(r.Client, agentCompName, cr, r.Scheme); err != nil { + return err + } + + log.Info("reconciling ArgoCD Agent's Agent deployment") + if err := agent.ReconcileAgentDeployment(r.Client, agentCompName, agentSa.Name, cr, r.Scheme); err != nil { + return err + } + return nil } diff --git a/controllers/argocdagent/agent/deployment.go b/controllers/argocdagent/agent/deployment.go new file mode 100644 index 000000000..f7e40312f --- /dev/null +++ b/controllers/argocdagent/agent/deployment.go @@ -0,0 +1,493 @@ +// Copyright 2025 ArgoCD Operator Developers +// +// 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 agent + +import ( + "context" + "fmt" + "os" + "reflect" + "strconv" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apiError "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + argoproj "github.com/argoproj-labs/argocd-operator/api/v1beta1" + "github.com/argoproj-labs/argocd-operator/common" + "github.com/argoproj-labs/argocd-operator/controllers/argoutil" +) + +// ReconcileAgentDeployment reconciles the ArgoCD agent's agent deployment. +// It creates, updates, or deletes the deployment based on the ArgoCD CR configuration. +func ReconcileAgentDeployment(client client.Client, compName, saName string, cr *argoproj.ArgoCD, scheme *runtime.Scheme) error { + deployment := buildDeployment(compName, cr) + + // Check if deployment already exists + exists := true + if err := argoutil.FetchObject(client, cr.Namespace, deployment.Name, deployment); err != nil { + if !apiError.IsNotFound(err) { + return fmt.Errorf("failed to get existing agent deployment %s in namespace %s: %v", deployment.Name, cr.Namespace, err) + } + exists = false + } + + // If deployment exists, handle updates or deletion + if exists { + if !has(cr) || !cr.Spec.ArgoCDAgent.Agent.IsEnabled() { + argoutil.LogResourceDeletion(log, deployment, "agent deployment is being deleted as agent is disabled") + if err := client.Delete(context.TODO(), deployment); err != nil { + return fmt.Errorf("failed to delete agent deployment %s in namespace %s: %v", deployment.Name, cr.Namespace, err) + } + return nil + } + + deployment, changed := updateDeploymentIfChanged(compName, saName, cr, deployment) + if changed { + argoutil.LogResourceUpdate(log, deployment, "agent deployment is being updated") + if err := client.Update(context.TODO(), deployment); err != nil { + return fmt.Errorf("failed to update agent deployment %s in namespace %s: %v", deployment.Name, cr.Namespace, err) + } + } + return nil + } + + // If deployment doesn't exist and agent is disabled, nothing to do + if !has(cr) || !cr.Spec.ArgoCDAgent.Agent.IsEnabled() { + return nil + } + + if err := controllerutil.SetControllerReference(cr, deployment, scheme); err != nil { + return fmt.Errorf("failed to set ArgoCD CR %s as owner for service %s: %w", cr.Name, deployment.Name, err) + } + + argoutil.LogResourceCreation(log, deployment) + deployment.Spec = buildAgentSpec(compName, saName, cr) + if err := client.Create(context.TODO(), deployment); err != nil { + return fmt.Errorf("failed to create agent deployment %s in namespace %s: %v", deployment.Name, cr.Namespace, err) + } + return nil +} + +func buildDeployment(compName string, cr *argoproj.ArgoCD) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, compName), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, compName), + }, + } +} + +func buildAgentSpec(compName, saName string, cr *argoproj.ArgoCD) appsv1.DeploymentSpec { + return appsv1.DeploymentSpec{ + Selector: buildSelector(compName, cr), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: buildLabelsForAgent(cr.Name, compName), + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Image: buildAgentImage(cr), + ImagePullPolicy: corev1.PullAlways, + Name: generateAgentResourceName(cr.Name, compName), + Env: buildAgentContainerEnv(cr), + Args: buildArgs(compName), + SecurityContext: buildSecurityContext(), + Ports: buildPorts(), + VolumeMounts: buildVolumeMounts(), + }, + }, + ServiceAccountName: saName, + Volumes: buildVolumes(), + }, + }, + } +} + +func buildSelector(compName string, cr *argoproj.ArgoCD) *metav1.LabelSelector { + return &metav1.LabelSelector{ + MatchLabels: buildLabelsForAgent(cr.Name, compName), + } +} + +func buildSecurityContext() *corev1.SecurityContext { + return &corev1.SecurityContext{ + AllowPrivilegeEscalation: ptr.To(false), + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{ + "ALL", + }, + }, + ReadOnlyRootFilesystem: ptr.To(true), + RunAsNonRoot: ptr.To(true), + SeccompProfile: &corev1.SeccompProfile{ + Type: "RuntimeDefault", + }, + } +} + +func buildPorts() []corev1.ContainerPort { + return []corev1.ContainerPort{ + { + ContainerPort: 8181, + Name: "metrics", + Protocol: corev1.ProtocolTCP, + }, + { + ContainerPort: 8002, + Name: "healthz", + Protocol: corev1.ProtocolTCP, + }, + } +} + +func buildArgs(compName string) []string { + args := make([]string, 0) + args = append(args, compName) + return args +} + +func buildAgentImage(cr *argoproj.ArgoCD) string { + // Check CR specification first + if hasClient(cr) && cr.Spec.ArgoCDAgent.Agent.Client.Image != "" { + return cr.Spec.ArgoCDAgent.Agent.Client.Image + } + + // Value specified in the environment take precedence over the default + if env := os.Getenv(EnvArgoCDAgentImage); env != "" { + return env + } + + // Use the default image and version if not specified in the CR or environment variable + return common.ArgoCDAgentAgentDefaultImageName +} + +func buildVolumeMounts() []corev1.VolumeMount { + return []corev1.VolumeMount{ + { + Name: "userpass-passwd", + MountPath: "/app/config/creds", + }, + } +} + +func buildVolumes() []corev1.Volume { + return []corev1.Volume{ + { + Name: "userpass-passwd", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "argocd-agent-agent-userpass", + Items: []corev1.KeyToPath{ + { + Key: "credentials", + Path: "userpass.creds", + }, + }, + Optional: ptr.To(true), + }, + }, + }, + } +} + +// updateDeploymentIfChanged compares the current deployment with the desired state +// and updates it if any changes are detected. Returns the updated deployment and a boolean +// indicating whether any changes were made. +func updateDeploymentIfChanged(compName, saName string, cr *argoproj.ArgoCD, deployment *appsv1.Deployment) (*appsv1.Deployment, bool) { + changed := false + + if !reflect.DeepEqual(deployment.Spec.Selector, buildSelector(compName, cr)) { + log.Info("deployment selector is being updated") + changed = true + deployment.Spec.Selector = buildSelector(compName, cr) + } + + if !reflect.DeepEqual(deployment.Spec.Template.Spec.Containers[0].Image, buildAgentImage(cr)) { + log.Info("deployment image is being updated") + changed = true + deployment.Spec.Template.Spec.Containers[0].Image = buildAgentImage(cr) + } + + if !reflect.DeepEqual(deployment.Spec.Template.Spec.Containers[0].Name, generateAgentResourceName(cr.Name, compName)) { + log.Info("deployment container name is being updated") + changed = true + deployment.Spec.Template.Spec.Containers[0].Name = generateAgentResourceName(cr.Name, compName) + } + + if !reflect.DeepEqual(deployment.Spec.Template.Spec.Containers[0].Env, buildAgentContainerEnv(cr)) { + log.Info("deployment container env is being updated") + changed = true + deployment.Spec.Template.Spec.Containers[0].Env = buildAgentContainerEnv(cr) + } + + if !reflect.DeepEqual(deployment.Spec.Template.Spec.Containers[0].Args, buildArgs(compName)) { + log.Info("deployment container args is being updated") + changed = true + deployment.Spec.Template.Spec.Containers[0].Args = buildArgs(compName) + } + + if !reflect.DeepEqual(deployment.Spec.Template.Spec.Containers[0].SecurityContext, buildSecurityContext()) { + log.Info("deployment container security context is being updated") + changed = true + deployment.Spec.Template.Spec.Containers[0].SecurityContext = buildSecurityContext() + } + + if !reflect.DeepEqual(deployment.Spec.Template.Spec.Containers[0].Ports, buildPorts()) { + log.Info("deployment container ports is being updated") + changed = true + deployment.Spec.Template.Spec.Containers[0].Ports = buildPorts() + } + + if !reflect.DeepEqual(deployment.Spec.Template.Spec.ServiceAccountName, saName) { + log.Info("deployment service account name is being updated") + changed = true + deployment.Spec.Template.Spec.ServiceAccountName = saName + } + + return deployment, changed +} + +func buildAgentContainerEnv(cr *argoproj.ArgoCD) []corev1.EnvVar { + env := []corev1.EnvVar{ + { + Name: EnvArgoCDAgentLogLevel, + Value: getAgentLogLevel(cr), + }, + { + Name: EnvArgoCDAgentNamespace, + Value: cr.Namespace, + }, + { + Name: EnvArgoCDAgentServerAddress, + Value: getAgentPrincipalServerAddress(cr), + }, + { + Name: EnvArgoCDAgentServerPort, + Value: getAgentPrincipalServerPort(cr), + }, + { + Name: EnvArgoCDAgentLogFormat, + Value: getAgentLogFormat(cr), + }, + { + Name: EnvArgoCDAgentTLSSecretName, + Value: getAgentTLSSecretName(cr), + }, + { + Name: EnvArgoCDAgentTLSInsecure, + Value: getAgentTLSInsecure(cr), + }, + { + Name: EnvArgoCDAgentTLSRootCASecretName, + Value: getAgentTLSRootCASecretName(cr), + }, + { + Name: EnvArgoCDAgentMode, + Value: getAgentMode(cr), + }, + { + Name: EnvArgoCDAgentCreds, + Value: getAgentCreds(cr), + }, + { + Name: EnvArgoCDAgentEnableWebSocket, + Value: getAgentEnableWebSocket(cr), + }, + { + Name: EnvArgoCDAgentEnableCompression, + Value: getAgentEnableCompression(cr), + }, + { + Name: EnvArgoCDAgentKeepAliveInterval, + Value: getAgentKeepAliveInterval(cr), + }, + { + Name: EnvArgoCDAgentRedisAddress, + Value: getAgentRedisAddress(cr), + }, + { + Name: EnvArgoCDAgentEnableResourceProxy, + Value: "true", + }, + { + Name: EnvRedisPassword, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + Key: AgentRedisPasswordKey, + LocalObjectReference: corev1.LocalObjectReference{ + Name: fmt.Sprintf("%s-%s", cr.Name, AgentRedisSecretnameSuffix), + }, + Optional: ptr.To(true), + }, + }, + }, + } + + // Add custom environment variables if specified in the CR + if hasClient(cr) && cr.Spec.ArgoCDAgent.Agent.Client.Env != nil { + env = append(env, cr.Spec.ArgoCDAgent.Agent.Client.Env...) + } + + return env +} + +// These constants are environment variables that correspond to the environment variables +// used to configure Argo CD Agent's agent deployment, and should match the names exactly from the agent +const ( + EnvArgoCDAgentServerAddress = "ARGOCD_AGENT_REMOTE_SERVER" + EnvArgoCDAgentServerPort = "ARGOCD_AGENT_REMOTE_PORT" + EnvArgoCDAgentLogLevel = "ARGOCD_AGENT_LOG_LEVEL" + EnvArgoCDAgentLogFormat = "ARGOCD_AGENT_LOG_FORMAT" + EnvArgoCDAgentNamespace = "ARGOCD_AGENT_NAMESPACE" + EnvArgoCDAgentTLSSecretName = "ARGOCD_AGENT_TLS_SECRET_NAME" // #nosec G101 + EnvArgoCDAgentTLSInsecure = "ARGOCD_AGENT_TLS_INSECURE" + EnvArgoCDAgentTLSRootCASecretName = "ARGOCD_AGENT_TLS_ROOT_CA_SECRET_NAME" // #nosec G101 + EnvArgoCDAgentMode = "ARGOCD_AGENT_MODE" + EnvArgoCDAgentCreds = "ARGOCD_AGENT_CREDS" // #nosec G101 + EnvArgoCDAgentEnableWebSocket = "ARGOCD_AGENT_ENABLE_WEBSOCKET" + EnvArgoCDAgentEnableCompression = "ARGOCD_AGENT_ENABLE_COMPRESSION" + EnvArgoCDAgentKeepAliveInterval = "ARGOCD_AGENT_KEEP_ALIVE_PING_INTERVAL" + EnvArgoCDAgentEnableResourceProxy = "ARGOCD_AGENT_ENABLE_RESOURCE_PROXY" + EnvArgoCDAgentImage = "ARGOCD_AGENT_IMAGE" + EnvArgoCDAgentRedisAddress = "REDIS_ADDR" + EnvRedisPassword = "REDIS_PASSWORD" + AgentRedisPasswordKey = "admin.password" + AgentRedisSecretnameSuffix = "redis-initial-password" // #nosec G101 +) + +// Logging Configuration +func getAgentLogLevel(cr *argoproj.ArgoCD) string { + if hasClient(cr) && cr.Spec.ArgoCDAgent.Agent.Client.LogLevel != "" { + return cr.Spec.ArgoCDAgent.Agent.Client.LogLevel + } + return "info" +} + +func getAgentLogFormat(cr *argoproj.ArgoCD) string { + if hasClient(cr) && cr.Spec.ArgoCDAgent.Agent.Client.LogFormat != "" { + return cr.Spec.ArgoCDAgent.Agent.Client.LogFormat + } + return "text" +} + +func getAgentPrincipalServerAddress(cr *argoproj.ArgoCD) string { + if hasClient(cr) && cr.Spec.ArgoCDAgent.Agent.Client.PrincipalServerAddress != "" { + return cr.Spec.ArgoCDAgent.Agent.Client.PrincipalServerAddress + } + return "" +} + +func getAgentPrincipalServerPort(cr *argoproj.ArgoCD) string { + if hasClient(cr) && cr.Spec.ArgoCDAgent.Agent.Client.PrincipalServerPort != "" { + return cr.Spec.ArgoCDAgent.Agent.Client.PrincipalServerPort + } + return "443" +} + +func getAgentTLSSecretName(cr *argoproj.ArgoCD) string { + if hasTLS(cr) && cr.Spec.ArgoCDAgent.Agent.TLS.SecretName != "" { + return cr.Spec.ArgoCDAgent.Agent.TLS.SecretName + } + return "argocd-agent-client-tls" +} + +func getAgentTLSInsecure(cr *argoproj.ArgoCD) string { + if hasTLS(cr) && cr.Spec.ArgoCDAgent.Agent.TLS.Insecure != nil && *cr.Spec.ArgoCDAgent.Agent.TLS.Insecure { + return "true" + } + return "false" +} + +func getAgentTLSRootCASecretName(cr *argoproj.ArgoCD) string { + if hasTLS(cr) && cr.Spec.ArgoCDAgent.Agent.TLS.RootCASecretName != "" { + return cr.Spec.ArgoCDAgent.Agent.TLS.RootCASecretName + } + return "argocd-agent-ca" +} + +func getAgentMode(cr *argoproj.ArgoCD) string { + if hasClient(cr) && cr.Spec.ArgoCDAgent.Agent.Client.Mode != "" { + return cr.Spec.ArgoCDAgent.Agent.Client.Mode + } + return "managed" +} + +func getAgentCreds(cr *argoproj.ArgoCD) string { + if hasClient(cr) && cr.Spec.ArgoCDAgent.Agent.Client.Creds != "" { + return cr.Spec.ArgoCDAgent.Agent.Client.Creds + } + return "mtls:any" +} + +// WebSocket Configuration +func getAgentEnableWebSocket(cr *argoproj.ArgoCD) string { + if hasClient(cr) && cr.Spec.ArgoCDAgent.Agent.Client.EnableWebSocket != nil { + return strconv.FormatBool(*cr.Spec.ArgoCDAgent.Agent.Client.EnableWebSocket) + } + return "false" +} + +func getAgentEnableCompression(cr *argoproj.ArgoCD) string { + if hasClient(cr) && cr.Spec.ArgoCDAgent.Agent.Client.EnableCompression != nil { + return strconv.FormatBool(*cr.Spec.ArgoCDAgent.Agent.Client.EnableCompression) + } + return "false" +} + +// Keep Alive Configuration +func getAgentKeepAliveInterval(cr *argoproj.ArgoCD) string { + if hasClient(cr) && cr.Spec.ArgoCDAgent.Agent.Client.KeepAliveInterval != "" { + return cr.Spec.ArgoCDAgent.Agent.Client.KeepAliveInterval + } + return "30s" +} + +// Redis Configuration +func getAgentRedisAddress(cr *argoproj.ArgoCD) string { + if hasRedis(cr) && cr.Spec.ArgoCDAgent.Agent.Redis.ServerAddress != "" { + return cr.Spec.ArgoCDAgent.Agent.Redis.ServerAddress + } + return fmt.Sprintf("%s-%s:%d", cr.Name, "redis", common.ArgoCDDefaultRedisPort) +} + +func has(cr *argoproj.ArgoCD) bool { + return cr.Spec.ArgoCDAgent != nil && cr.Spec.ArgoCDAgent.Agent != nil +} + +func hasClient(cr *argoproj.ArgoCD) bool { + return cr.Spec.ArgoCDAgent != nil && + cr.Spec.ArgoCDAgent.Agent != nil && + cr.Spec.ArgoCDAgent.Agent.Client != nil +} + +func hasTLS(cr *argoproj.ArgoCD) bool { + return cr.Spec.ArgoCDAgent != nil && + cr.Spec.ArgoCDAgent.Agent != nil && + cr.Spec.ArgoCDAgent.Agent.TLS != nil +} + +func hasRedis(cr *argoproj.ArgoCD) bool { + return cr.Spec.ArgoCDAgent != nil && + cr.Spec.ArgoCDAgent.Agent != nil && + cr.Spec.ArgoCDAgent.Agent.Redis != nil +} diff --git a/controllers/argocdagent/agent/deployment_test.go b/controllers/argocdagent/agent/deployment_test.go new file mode 100644 index 000000000..22ebb9049 --- /dev/null +++ b/controllers/argocdagent/agent/deployment_test.go @@ -0,0 +1,537 @@ +// Copyright 2025 ArgoCD Operator Developers +// +// 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 agent + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + argoproj "github.com/argoproj-labs/argocd-operator/api/v1beta1" + "github.com/argoproj-labs/argocd-operator/common" +) + +// Helper function to create a test deployment +func makeTestDeployment(cr *argoproj.ArgoCD) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Spec: buildAgentSpec(testAgentCompName, generateAgentResourceName(cr.Name, testAgentCompName), cr), + } +} + +// Helper function to create a test deployment with custom image +func makeTestDeploymentWithCustomImage(cr *argoproj.ArgoCD, customImage string) *appsv1.Deployment { + deployment := makeTestDeployment(cr) + deployment.Spec.Template.Spec.Containers[0].Image = customImage + return deployment +} + +// Helper function to create ArgoCD with custom principal image +func withAgentImage(image string) argoCDOpt { + return func(a *argoproj.ArgoCD) { + if a.Spec.ArgoCDAgent == nil { + a.Spec.ArgoCDAgent = &argoproj.ArgoCDAgentSpec{} + } + if a.Spec.ArgoCDAgent.Agent == nil { + a.Spec.ArgoCDAgent.Agent = &argoproj.AgentSpec{} + } + if a.Spec.ArgoCDAgent.Agent.Client == nil { + a.Spec.ArgoCDAgent.Agent.Client = &argoproj.AgentClientSpec{} + } + a.Spec.ArgoCDAgent.Agent.Client.Image = image + } +} + +// TestReconcileAgentDeployment tests + +func TestReconcileAgentDeployment_DeploymentDoesNotExist_AgentDisabled(t *testing.T) { + // Test case: Deployment doesn't exist and agent is disabled + // Expected behavior: Should do nothing (no creation, no error) + + cr := makeTestArgoCD(withAgentEnabled(false)) + saName := generateAgentResourceName(cr.Name, testAgentCompName) + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentDeployment(cl, testAgentCompName, saName, cr, sch) + assert.NoError(t, err) + + // Verify Deployment was not created + deployment := &appsv1.Deployment{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, deployment) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentDeployment_DeploymentDoesNotExist_AgentEnabled(t *testing.T) { + // Test case: Deployment doesn't exist and agent is enabled + // Expected behavior: Should create the Deployment with expected spec + + cr := makeTestArgoCD(withAgentEnabled(true)) + saName := generateAgentResourceName(cr.Name, testAgentCompName) + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentDeployment(cl, testAgentCompName, saName, cr, sch) + assert.NoError(t, err) + + // Verify Deployment was created + deployment := &appsv1.Deployment{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, deployment) + assert.NoError(t, err) + + // Verify Deployment has expected metadata + assert.Equal(t, generateAgentResourceName(cr.Name, testAgentCompName), deployment.Name) + assert.Equal(t, cr.Namespace, deployment.Namespace) + assert.Equal(t, buildLabelsForAgent(cr.Name, testAgentCompName), deployment.Labels) + + // Verify Deployment has expected spec + expectedSpec := buildAgentSpec(testAgentCompName, saName, cr) + assert.Equal(t, expectedSpec.Selector, deployment.Spec.Selector) + assert.Equal(t, expectedSpec.Template.Labels, deployment.Spec.Template.Labels) + assert.Equal(t, expectedSpec.Template.Spec.ServiceAccountName, deployment.Spec.Template.Spec.ServiceAccountName) + + // Verify container configuration + assert.Len(t, deployment.Spec.Template.Spec.Containers, 1) + container := deployment.Spec.Template.Spec.Containers[0] + assert.Equal(t, generateAgentResourceName(cr.Name, testAgentCompName), container.Name) + assert.Equal(t, buildAgentImage(cr), container.Image) + assert.Equal(t, corev1.PullAlways, container.ImagePullPolicy) + assert.Equal(t, buildArgs(testAgentCompName), container.Args) + assert.Equal(t, buildAgentContainerEnv(cr), container.Env) + assert.Equal(t, buildSecurityContext(), container.SecurityContext) + assert.Equal(t, buildPorts(), container.Ports) + assert.Equal(t, buildVolumeMounts(), container.VolumeMounts) + + // Verify pod volumes configuration + assert.Equal(t, buildVolumes(), deployment.Spec.Template.Spec.Volumes) + + // Verify owner reference is set + assert.Len(t, deployment.OwnerReferences, 1) + assert.Equal(t, cr.Name, deployment.OwnerReferences[0].Name) + assert.Equal(t, "ArgoCD", deployment.OwnerReferences[0].Kind) +} + +func TestReconcileAgentDeployment_DeploymentExists_AgentDisabled(t *testing.T) { + // Test case: Deployment exists and agent is disabled + // Expected behavior: Should delete the Deployment + + cr := makeTestArgoCD(withAgentEnabled(false)) + saName := generateAgentResourceName(cr.Name, testAgentCompName) + + // Create existing Deployment + existingDeployment := makeTestDeployment(cr) + + resObjs := []client.Object{cr, existingDeployment} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentDeployment(cl, testAgentCompName, saName, cr, sch) + assert.NoError(t, err) + + // Verify Deployment was deleted + deployment := &appsv1.Deployment{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, deployment) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentDeployment_DeploymentExists_AgentEnabled_NoChanges(t *testing.T) { + // Test case: Deployment exists, agent is enabled, and no changes are needed + // Expected behavior: Should not update the Deployment + + cr := makeTestArgoCD(withAgentEnabled(true)) + saName := generateAgentResourceName(cr.Name, testAgentCompName) + + // Create existing Deployment with correct spec + existingDeployment := makeTestDeployment(cr) + + resObjs := []client.Object{cr, existingDeployment} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentDeployment(cl, testAgentCompName, saName, cr, sch) + assert.NoError(t, err) + + // Verify Deployment still exists with same spec + deployment := &appsv1.Deployment{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, deployment) + assert.NoError(t, err) + assert.Equal(t, buildAgentImage(cr), deployment.Spec.Template.Spec.Containers[0].Image) + assert.Equal(t, saName, deployment.Spec.Template.Spec.ServiceAccountName) +} + +func TestReconcileAgentDeployment_DeploymentExists_AgentEnabled_ImageChanged(t *testing.T) { + // Test case: Deployment exists, agent is enabled, but image has changed + // Expected behavior: Should update the Deployment with new image + + cr := makeTestArgoCD(withAgentEnabled(true), withAgentImage("quay.io/argoproj/argocd-agent:v2")) + saName := generateAgentResourceName(cr.Name, testAgentCompName) + + // Create existing Deployment with old image + existingDeployment := makeTestDeploymentWithCustomImage(cr, "quay.io/argoproj/argocd-agent:v1") + + resObjs := []client.Object{cr, existingDeployment} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentDeployment(cl, testAgentCompName, saName, cr, sch) + assert.NoError(t, err) + + // Verify Deployment was updated with new image + deployment := &appsv1.Deployment{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, deployment) + assert.NoError(t, err) + assert.Equal(t, "quay.io/argoproj/argocd-agent:v2", deployment.Spec.Template.Spec.Containers[0].Image) +} + +func TestReconcileAgentDeployment_DeploymentExists_AgentEnabled_ServiceAccountChanged(t *testing.T) { + // Test case: Deployment exists, agent is enabled, but service account has changed + // Expected behavior: Should update the Deployment with new service account + + cr := makeTestArgoCD(withAgentEnabled(true)) + oldSAName := "old-service-account" + newSAName := "new-service-account" + + // Create existing Deployment with old service account + existingDeployment := makeTestDeployment(cr) + existingDeployment.Spec.Template.Spec.ServiceAccountName = oldSAName + + resObjs := []client.Object{cr, existingDeployment} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentDeployment(cl, testAgentCompName, newSAName, cr, sch) + assert.NoError(t, err) + + // Verify Deployment was updated with new service account + deployment := &appsv1.Deployment{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, deployment) + assert.NoError(t, err) + assert.Equal(t, newSAName, deployment.Spec.Template.Spec.ServiceAccountName) +} + +func TestReconcileAgentDeployment_DeploymentExists_AgentNotSet(t *testing.T) { + // Test case: Deployment exists but agent is not set (nil) + // Expected behavior: Should delete the Deployment + + cr := makeTestArgoCD() // No agent configuration + saName := generateAgentResourceName(cr.Name, testAgentCompName) + + // Create existing Deployment + existingDeployment := makeTestDeployment(cr) + + resObjs := []client.Object{cr, existingDeployment} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentDeployment(cl, testAgentCompName, saName, cr, sch) + assert.NoError(t, err) + + // Verify Deployment was deleted + deployment := &appsv1.Deployment{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, deployment) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentDeployment_DeploymentDoesNotExist_AgentNotSet(t *testing.T) { + // Test case: Deployment doesn't exist and ArgoCDAgent is not set (nil) + // Expected behavior: Should do nothing since agent is effectively disabled + + cr := makeTestArgoCD() // No agent configuration + saName := generateAgentResourceName(cr.Name, testAgentCompName) + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentDeployment(cl, testAgentCompName, saName, cr, sch) + assert.NoError(t, err) + + // Verify Deployment was not created + deployment := &appsv1.Deployment{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, deployment) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentDeployment_VerifyDeploymentSpec(t *testing.T) { + // Test case: Verify the deployment spec has correct configuration + // Expected behavior: Should create deployment with correct security context, ports, etc. + + cr := makeTestArgoCD(withAgentEnabled(true)) + saName := generateAgentResourceName(cr.Name, testAgentCompName) + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentDeployment(cl, testAgentCompName, saName, cr, sch) + assert.NoError(t, err) + + // Verify Deployment was created + deployment := &appsv1.Deployment{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, deployment) + assert.NoError(t, err) + + // Verify security context + container := deployment.Spec.Template.Spec.Containers[0] + assert.Equal(t, ptr.To(false), container.SecurityContext.AllowPrivilegeEscalation) + assert.Equal(t, ptr.To(true), container.SecurityContext.ReadOnlyRootFilesystem) + assert.Equal(t, ptr.To(true), container.SecurityContext.RunAsNonRoot) + assert.Equal(t, []corev1.Capability{"ALL"}, container.SecurityContext.Capabilities.Drop) + assert.Equal(t, corev1.SeccompProfileType("RuntimeDefault"), container.SecurityContext.SeccompProfile.Type) + + // Verify ports configuration (agent only has metrics and healthz) + assert.Len(t, container.Ports, 2) + metricsPort := container.Ports[0] + assert.Equal(t, "metrics", metricsPort.Name) + assert.Equal(t, int32(8181), metricsPort.ContainerPort) + assert.Equal(t, corev1.ProtocolTCP, metricsPort.Protocol) + healthzPort := container.Ports[1] + assert.Equal(t, "healthz", healthzPort.Name) + assert.Equal(t, int32(8002), healthzPort.ContainerPort) + assert.Equal(t, corev1.ProtocolTCP, healthzPort.Protocol) + + // Verify args + assert.Equal(t, []string{testAgentCompName}, container.Args) + + // Verify environment variables are set + assert.True(t, len(container.Env) > 0) + // Verify some expected environment variables are present + envNames := make(map[string]bool) + for _, env := range container.Env { + envNames[env.Name] = true + // Most environment variables should have direct values, except for secrets like Redis password + if env.Name == "REDIS_PASSWORD" { + assert.NotNil(t, env.ValueFrom, "REDIS_PASSWORD should reference a secret") + assert.NotNil(t, env.ValueFrom.SecretKeyRef, "REDIS_PASSWORD should reference a secret key") + assert.Equal(t, "argocd-redis-initial-password", env.ValueFrom.SecretKeyRef.Name) + assert.Equal(t, "admin.password", env.ValueFrom.SecretKeyRef.Key) + } else { + // All other environment variables should have direct values, not references + assert.Nil(t, env.ValueFrom, "Environment variable %s should have direct value, not reference", env.Name) + } + } + // Check for some agent-specific environment variables + assert.True(t, envNames["ARGOCD_AGENT_REMOTE_SERVER"], "ARGOCD_AGENT_REMOTE_SERVER should be set") + assert.True(t, envNames["ARGOCD_AGENT_LOG_LEVEL"], "ARGOCD_AGENT_LOG_LEVEL should be set") +} + +func TestReconcileAgentDeployment_CustomImage(t *testing.T) { + // Test case: Verify custom image is used when specified + // Expected behavior: Should create deployment with custom image + + customImage := "custom-registry/argocd-agent:custom-tag" + cr := makeTestArgoCD(withAgentEnabled(true), withAgentImage(customImage)) + saName := generateAgentResourceName(cr.Name, testAgentCompName) + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentDeployment(cl, testAgentCompName, saName, cr, sch) + assert.NoError(t, err) + + // Verify Deployment was created with custom image + deployment := &appsv1.Deployment{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, deployment) + assert.NoError(t, err) + assert.Equal(t, customImage, deployment.Spec.Template.Spec.Containers[0].Image) +} + +func TestReconcileAgentDeployment_DefaultImage(t *testing.T) { + // Test case: Verify default image is used when no custom image is specified + // Expected behavior: Should create deployment with default image + + cr := makeTestArgoCD(withAgentEnabled(true)) + saName := generateAgentResourceName(cr.Name, testAgentCompName) + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentDeployment(cl, testAgentCompName, saName, cr, sch) + assert.NoError(t, err) + + // Verify Deployment was created with default image + deployment := &appsv1.Deployment{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, deployment) + assert.NoError(t, err) + assert.Equal(t, common.ArgoCDAgentAgentDefaultImageName, deployment.Spec.Template.Spec.Containers[0].Image) +} + +func TestReconcileAgentDeployment_VolumeMountsAndVolumes(t *testing.T) { + // Test case: Verify volume mounts and volumes are correctly configured + // Expected behavior: Should create deployment with JWT and userpass volume mounts and volumes + + cr := makeTestArgoCD(withAgentEnabled(true)) + saName := generateAgentResourceName(cr.Name, testAgentCompName) + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentDeployment(cl, testAgentCompName, saName, cr, sch) + assert.NoError(t, err) + + // Verify Deployment was created + deployment := &appsv1.Deployment{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, deployment) + assert.NoError(t, err) + + // Verify volume mounts + container := deployment.Spec.Template.Spec.Containers[0] + assert.Equal(t, buildVolumeMounts(), container.VolumeMounts) + + // Verify volumes + assert.Equal(t, buildVolumes(), deployment.Spec.Template.Spec.Volumes) + + // Verify specific volume mount details (agent only has userpass-passwd) + assert.Len(t, container.VolumeMounts, 1) + userpassMount := container.VolumeMounts[0] + assert.Equal(t, "userpass-passwd", userpassMount.Name) + assert.Equal(t, "/app/config/creds", userpassMount.MountPath) + + // Verify specific volume details (agent only has userpass-passwd) + assert.Len(t, deployment.Spec.Template.Spec.Volumes, 1) + userpassVolume := deployment.Spec.Template.Spec.Volumes[0] + assert.Equal(t, "userpass-passwd", userpassVolume.Name) + assert.Equal(t, "argocd-agent-agent-userpass", userpassVolume.Secret.SecretName) + assert.Equal(t, ptr.To(true), userpassVolume.Secret.Optional) +} + +func TestBuildAgentImage(t *testing.T) { + tests := []struct { + name string + cr *argoproj.ArgoCD + envImage string + expectedImage string + description string + }{ + { + name: "CR specification takes precedence", + cr: makeTestArgoCD( + withAgentEnabled(true), + withAgentImage("custom-registry/argocd-agent:custom-tag"), + ), + envImage: "env-registry/argocd-agent:env-tag", + expectedImage: "custom-registry/argocd-agent:custom-tag", + description: "When CR specifies an image, it should take precedence over environment variable and default", + }, + { + name: "Environment variable used when CR image not specified", + cr: makeTestArgoCD( + withAgentEnabled(true), + ), + envImage: "env-registry/argocd-agent:env-tag", + expectedImage: "env-registry/argocd-agent:env-tag", + description: "When CR doesn't specify an image but environment variable is set, use environment variable", + }, + { + name: "Default image used when neither CR nor environment specified", + cr: makeTestArgoCD( + withAgentEnabled(true), + ), + envImage: "", + expectedImage: common.ArgoCDAgentAgentDefaultImageName, + description: "When neither CR nor environment variable specifies an image, use default", + }, + { + name: "Empty CR image should not override environment variable", + cr: makeTestArgoCD( + withAgentEnabled(true), + withAgentImage(""), + ), + envImage: "env-registry/argocd-agent:env-tag", + expectedImage: "env-registry/argocd-agent:env-tag", + description: "When CR specifies empty image, environment variable should be used", + }, + { + name: "Default image used when CR image is empty and no environment variable", + cr: makeTestArgoCD( + withAgentEnabled(true), + withAgentImage(""), + ), + envImage: "", + expectedImage: common.ArgoCDAgentAgentDefaultImageName, + description: "When CR specifies empty image and no environment variable, use default", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set environment variable if specified + if tt.envImage != "" { + t.Setenv("ARGOCD_AGENT_IMAGE", tt.envImage) + } else { + // Clear environment variable + t.Setenv("ARGOCD_AGENT_IMAGE", "") + } + + result := buildAgentImage(tt.cr) + assert.Equal(t, tt.expectedImage, result, tt.description) + }) + } +} diff --git a/controllers/argocdagent/agent/role.go b/controllers/argocdagent/agent/role.go new file mode 100644 index 000000000..c6a0b7558 --- /dev/null +++ b/controllers/argocdagent/agent/role.go @@ -0,0 +1,232 @@ +// Copyright 2025 ArgoCD Operator Developers +// +// 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 agent + +import ( + "context" + "fmt" + "reflect" + + v1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + argoproj "github.com/argoproj-labs/argocd-operator/api/v1beta1" + "github.com/argoproj-labs/argocd-operator/controllers/argoutil" +) + +// ReconcileAgentRole manages the lifecycle of a Role resource for the ArgoCD agent. +// This function creates, updates, or deletes the Role based on the agent's enabled status. +func ReconcileAgentRole(client client.Client, compName string, cr *argoproj.ArgoCD, scheme *runtime.Scheme) (*v1.Role, error) { + role := buildRole(compName, cr) + expectedPolicyRule := buildPolicyRuleForRole() + + // Check if the Role already exists + exists := true + if err := client.Get(context.TODO(), types.NamespacedName{Name: role.Name, Namespace: role.Namespace}, role); err != nil { + if !errors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get existing agent role %s in namespace %s: %v", role.Name, role.Namespace, err) + } + exists = false + } + + // If Role exists, handle updates or deletion + if exists { + if !has(cr) || !cr.Spec.ArgoCDAgent.Agent.IsEnabled() { + argoutil.LogResourceDeletion(log, role, "agent role is being deleted as agent is disabled") + if err := client.Delete(context.TODO(), role); err != nil { + return role, fmt.Errorf("failed to delete agent role %s: %w", role.Name, err) + } + return role, nil + } + + if !reflect.DeepEqual(expectedPolicyRule, role.Rules) { + role.Rules = expectedPolicyRule + argoutil.LogResourceUpdate(log, role, "agent role rules are being updated") + if err := client.Update(context.TODO(), role); err != nil { + return nil, fmt.Errorf("failed to update agent role %s: %w", role.Name, err) + } + } + return role, nil + } + + // If Role doesn't exist and agent is disabled, nothing to do + if !has(cr) || !cr.Spec.ArgoCDAgent.Agent.IsEnabled() { + return role, nil + } + + if err := controllerutil.SetControllerReference(cr, role, scheme); err != nil { + return nil, fmt.Errorf("failed to set ArgoCD CR %s as owner for role %s: %v", cr.Name, role.Name, err) + } + + role.Rules = expectedPolicyRule + + argoutil.LogResourceCreation(log, role) + if err := client.Create(context.TODO(), role); err != nil { + return nil, fmt.Errorf("failed to create agent role %s: %v", role.Name, err) + } + return role, nil +} + +// ReconcileAgentClusterRoles manages the lifecycle of a ClusterRole resource for the ArgoCD agent. +// This function creates, updates, or deletes the ClusterRole based on the agent's enabled status. +func ReconcileAgentClusterRoles(client client.Client, compName string, cr *argoproj.ArgoCD, scheme *runtime.Scheme) (*v1.ClusterRole, error) { + clusterRole := buildClusterRole(compName, cr) + expectedPolicyRule := buildPolicyRuleForClusterRole() + + // Check if the ClusterRole already exists + exists := true + if err := client.Get(context.TODO(), types.NamespacedName{Name: clusterRole.Name}, clusterRole); err != nil { + if !errors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get existing agent clusterRole %s: %v", clusterRole.Name, err) + } + exists = false + } + + // If ClusterRole exists, handle updates or deletion + if exists { + if !has(cr) || !cr.Spec.ArgoCDAgent.Agent.IsEnabled() { + argoutil.LogResourceDeletion(log, clusterRole, "agent clusterRole is being deleted as agent is disabled") + if err := client.Delete(context.TODO(), clusterRole); err != nil { + return clusterRole, fmt.Errorf("failed to delete agent clusterRole %s: %v", clusterRole.Name, err) + } + return clusterRole, nil + } + + if !reflect.DeepEqual(expectedPolicyRule, clusterRole.Rules) { + clusterRole.Rules = expectedPolicyRule + argoutil.LogResourceUpdate(log, clusterRole, "agent clusterRole rules are being updated") + if err := client.Update(context.TODO(), clusterRole); err != nil { + return nil, fmt.Errorf("failed to update agent clusterRole %s: %v", clusterRole.Name, err) + } + } + return clusterRole, nil + } + + // If ClusterRole doesn't exist and agent is disabled, nothing to do + if !has(cr) || !cr.Spec.ArgoCDAgent.Agent.IsEnabled() { + return clusterRole, nil + } + + clusterRole.Rules = expectedPolicyRule + + argoutil.LogResourceCreation(log, clusterRole) + if err := client.Create(context.TODO(), clusterRole); err != nil { + return nil, fmt.Errorf("failed to create agent clusterRole %s: %v", clusterRole.Name, err) + } + return clusterRole, nil +} + +func buildRole(compName string, cr *argoproj.ArgoCD) *v1.Role { + return &v1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, compName), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, compName), + }, + } +} + +func buildClusterRole(compName string, cr *argoproj.ArgoCD) *v1.ClusterRole { + return &v1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, compName), + Labels: buildLabelsForAgent(cr.Name, compName), + }, + } +} + +// buildPolicyRuleForRole defines the namespace-scoped permissions for the ArgoCD agent. +// Grants access to: +// - argoproj.io resources (applications, appprojects, applicationsets): full CRUD operations +// - secrets and configmaps: full CRUD operations for configuration management +// - events: create and list permissions for logging and monitoring +func buildPolicyRuleForRole() []v1.PolicyRule { + return []v1.PolicyRule{ + { + APIGroups: []string{ + "argoproj.io", + }, + Resources: []string{ + "applications", + "appprojects", + "applicationsets", + }, + Verbs: []string{ + "create", + "get", + "list", + "watch", + "update", + "delete", + "patch", + }, + }, + { + APIGroups: []string{ + "", + }, + Resources: []string{ + "secrets", + "configmaps", + }, + Verbs: []string{ + "create", + "get", + "list", + "watch", + "update", + "patch", + "delete", + }, + }, + { + APIGroups: []string{ + "", + }, + Resources: []string{ + "events", + }, + Verbs: []string{ + "create", + "list", + }, + }, + } +} + +// buildPolicyRuleForClusterRole defines the cluster-scoped permissions for the ArgoCD agent. +// Grants access to: +// - namespaces: list and watch permissions for discovering and monitoring cluster namespaces +func buildPolicyRuleForClusterRole() []v1.PolicyRule { + return []v1.PolicyRule{ + { + APIGroups: []string{ + "", + }, + Resources: []string{ + "namespaces", + }, + Verbs: []string{ + "list", + "watch", + }, + }, + } +} diff --git a/controllers/argocdagent/agent/role_test.go b/controllers/argocdagent/agent/role_test.go new file mode 100644 index 000000000..bef358960 --- /dev/null +++ b/controllers/argocdagent/agent/role_test.go @@ -0,0 +1,422 @@ +// Copyright 2025 ArgoCD Operator Developers +// +// 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 agent + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// TestReconcileAgentRole tests + +func TestReconcileAgentRole_RoleDoesNotExist_AgentDisabled(t *testing.T) { + // Test case: Role doesn't exist and agent is disabled + // Expected behavior: Should do nothing (no creation, no error) + + cr := makeTestArgoCD(withAgentEnabled(false)) + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + role, err := ReconcileAgentRole(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, role) + + // Verify Role was not created + retrievedRole := &v1.Role{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, retrievedRole) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentRole_RoleDoesNotExist_AgentEnabled(t *testing.T) { + // Test case: Role doesn't exist and agent is enabled + // Expected behavior: Should create the Role with expected rules + + cr := makeTestArgoCD(withAgentEnabled(true)) + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + role, err := ReconcileAgentRole(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, role) + + // Verify Role was created + retrievedRole := &v1.Role{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, retrievedRole) + assert.NoError(t, err) + + // Verify Role has expected metadata + assert.Equal(t, generateAgentResourceName(cr.Name, testAgentCompName), retrievedRole.Name) + assert.Equal(t, cr.Namespace, retrievedRole.Namespace) + assert.Equal(t, buildLabelsForAgent(cr.Name, testAgentCompName), retrievedRole.Labels) + + // Verify Role has expected rules + expectedRules := buildPolicyRuleForRole() + assert.Equal(t, expectedRules, retrievedRole.Rules) + + // Verify owner reference is set + assert.Len(t, retrievedRole.OwnerReferences, 1) + assert.Equal(t, cr.Name, retrievedRole.OwnerReferences[0].Name) + assert.Equal(t, "ArgoCD", retrievedRole.OwnerReferences[0].Kind) +} + +func TestReconcileAgentRole_RoleExists_AgentDisabled(t *testing.T) { + // Test case: Role exists and agent is disabled + // Expected behavior: Should delete the Role + + cr := makeTestArgoCD(withAgentEnabled(false)) + + // Create existing Role + existingRole := &v1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Rules: buildPolicyRuleForRole(), + } + + resObjs := []client.Object{cr, existingRole} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + role, err := ReconcileAgentRole(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, role) + + // Verify Role was deleted + retrievedRole := &v1.Role{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, retrievedRole) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentRole_RoleExists_AgentEnabled_SameRules(t *testing.T) { + // Test case: Role exists, agent is enabled, and rules are the same + // Expected behavior: Should do nothing (no update) + + cr := makeTestArgoCD(withAgentEnabled(true)) + + expectedRules := buildPolicyRuleForRole() + existingRole := &v1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Rules: expectedRules, + } + + resObjs := []client.Object{cr, existingRole} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + role, err := ReconcileAgentRole(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, role) + + // Verify Role still exists with same rules + retrievedRole := &v1.Role{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, retrievedRole) + assert.NoError(t, err) + assert.Equal(t, expectedRules, retrievedRole.Rules) +} + +func TestReconcileAgentRole_RoleExists_AgentEnabled_DifferentRules(t *testing.T) { + // Test case: Role exists, agent is enabled, but rules are different + // Expected behavior: Should update the Role with new rules + + cr := makeTestArgoCD(withAgentEnabled(true)) + + // Create existing Role with different rules + existingRole := &v1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Rules: []v1.PolicyRule{ + { + APIGroups: []string{"apps"}, + Resources: []string{"deployments"}, + Verbs: []string{"get", "list"}, + }, + }, + } + + resObjs := []client.Object{cr, existingRole} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + role, err := ReconcileAgentRole(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, role) + + // Verify Role was updated with expected rules + retrievedRole := &v1.Role{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, retrievedRole) + assert.NoError(t, err) + + expectedRules := buildPolicyRuleForRole() + assert.Equal(t, expectedRules, retrievedRole.Rules) +} + +func TestReconcileAgentRole_RoleExists_AgentNotSet(t *testing.T) { + // Test case: Role exists but agent is not set (nil) + // Expected behavior: Should delete the Role + + cr := makeTestArgoCD() // No agent configuration + + // Create existing Role + existingRole := &v1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Rules: buildPolicyRuleForRole(), + } + + resObjs := []client.Object{cr, existingRole} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + role, err := ReconcileAgentRole(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, role) + + // Verify Role was deleted (since agent is not enabled by default) + retrievedRole := &v1.Role{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, retrievedRole) + assert.True(t, errors.IsNotFound(err)) +} + +// TestReconcileAgentClusterRoles tests + +func TestReconcileAgentClusterRoles_ClusterRoleDoesNotExist_AgentDisabled(t *testing.T) { + // Test case: ClusterRole doesn't exist and agent is disabled + // Expected behavior: Should do nothing (no creation, no error) + + cr := makeTestArgoCD(withAgentEnabled(false)) + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + clusterRole, err := ReconcileAgentClusterRoles(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, clusterRole) + + // Verify ClusterRole was not created + retrievedClusterRole := &v1.ClusterRole{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + }, retrievedClusterRole) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentClusterRoles_ClusterRoleDoesNotExist_AgentEnabled(t *testing.T) { + // Test case: ClusterRole doesn't exist and agent is enabled + // Expected behavior: Should create the ClusterRole with expected rules + + cr := makeTestArgoCD(withAgentEnabled(true)) + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + clusterRole, err := ReconcileAgentClusterRoles(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, clusterRole) + + // Verify ClusterRole was created + retrievedClusterRole := &v1.ClusterRole{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + }, retrievedClusterRole) + assert.NoError(t, err) + + // Verify ClusterRole has expected metadata + assert.Equal(t, generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), retrievedClusterRole.Name) + assert.Equal(t, buildLabelsForAgent(cr.Name, testAgentCompName), retrievedClusterRole.Labels) + + // Verify ClusterRole has expected rules + expectedRules := buildPolicyRuleForClusterRole() + assert.Equal(t, expectedRules, retrievedClusterRole.Rules) + + // Verify no owner reference is set for ClusterRole (as expected from the code) + assert.Len(t, retrievedClusterRole.OwnerReferences, 0) +} + +func TestReconcileAgentClusterRoles_ClusterRoleExists_AgentDisabled(t *testing.T) { + // Test case: ClusterRole exists and agent is disabled + // Expected behavior: Should delete the ClusterRole + + cr := makeTestArgoCD(withAgentEnabled(false)) + + // Create existing ClusterRole + existingClusterRole := &v1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Rules: buildPolicyRuleForClusterRole(), + } + + resObjs := []client.Object{cr, existingClusterRole} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + clusterRole, err := ReconcileAgentClusterRoles(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, clusterRole) + + // Verify ClusterRole was deleted + retrievedClusterRole := &v1.ClusterRole{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + }, retrievedClusterRole) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentClusterRoles_ClusterRoleExists_AgentEnabled_SameRules(t *testing.T) { + // Test case: ClusterRole exists, agent is enabled, and rules are the same + // Expected behavior: Should do nothing (no update) + + cr := makeTestArgoCD(withAgentEnabled(true)) + + expectedRules := buildPolicyRuleForClusterRole() + existingClusterRole := &v1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Rules: expectedRules, + } + + resObjs := []client.Object{cr, existingClusterRole} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + clusterRole, err := ReconcileAgentClusterRoles(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, clusterRole) + + // Verify ClusterRole still exists with same rules + retrievedClusterRole := &v1.ClusterRole{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + }, retrievedClusterRole) + assert.NoError(t, err) + assert.Equal(t, expectedRules, retrievedClusterRole.Rules) +} + +func TestReconcileAgentClusterRoles_ClusterRoleExists_AgentEnabled_DifferentRules(t *testing.T) { + // Test case: ClusterRole exists, agent is enabled, but rules are different + // Expected behavior: Should update the ClusterRole with new rules + + cr := makeTestArgoCD(withAgentEnabled(true)) + + // Create existing ClusterRole with different rules + existingClusterRole := &v1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Rules: []v1.PolicyRule{ + { + APIGroups: []string{"apps"}, + Resources: []string{"deployments"}, + Verbs: []string{"get", "list"}, + }, + }, + } + + resObjs := []client.Object{cr, existingClusterRole} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + clusterRole, err := ReconcileAgentClusterRoles(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, clusterRole) + + // Verify ClusterRole was updated with expected rules + retrievedClusterRole := &v1.ClusterRole{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + }, retrievedClusterRole) + assert.NoError(t, err) + + expectedRules := buildPolicyRuleForClusterRole() + assert.Equal(t, expectedRules, retrievedClusterRole.Rules) +} + +func TestReconcileAgentClusterRoles_ClusterRoleExists_AgentNotSet(t *testing.T) { + // Test case: ClusterRole exists but agent is not set (nil) + // Expected behavior: Should delete the ClusterRole + + cr := makeTestArgoCD() // No agent configuration + + // Create existing ClusterRole + existingClusterRole := &v1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Rules: buildPolicyRuleForClusterRole(), + } + + resObjs := []client.Object{cr, existingClusterRole} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + clusterRole, err := ReconcileAgentClusterRoles(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, clusterRole) + + // Verify ClusterRole was deleted (since agent is not enabled by default) + retrievedClusterRole := &v1.ClusterRole{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + }, retrievedClusterRole) + assert.True(t, errors.IsNotFound(err)) +} diff --git a/controllers/argocdagent/agent/rolebinding.go b/controllers/argocdagent/agent/rolebinding.go new file mode 100644 index 000000000..ba85ac540 --- /dev/null +++ b/controllers/argocdagent/agent/rolebinding.go @@ -0,0 +1,194 @@ +// Copyright 2025 ArgoCD Operator Developers +// +// 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 agent + +import ( + "context" + "fmt" + "reflect" + + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/api/rbac/v1" + apiError "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + argoproj "github.com/argoproj-labs/argocd-operator/api/v1beta1" + "github.com/argoproj-labs/argocd-operator/controllers/argoutil" +) + +// ReconcileAgentRoleBinding reconciles a RoleBinding for the ArgoCD Agent's agent component. +// This function handles the creation, update, and deletion of RoleBindings based on the agent's enabled state. +func ReconcileAgentRoleBinding(client client.Client, compName string, sa *corev1.ServiceAccount, cr *argoproj.ArgoCD, scheme *runtime.Scheme) error { + roleBinding := buildRoleBinding(compName, cr) + expectedSubjects := buildSubjects(sa, cr) + expectedRoleRef := buildRoleRef(generateAgentResourceName(cr.Name, compName), "Role") + + // Check if the RoleBinding already exists + exists := true + if err := client.Get(context.TODO(), types.NamespacedName{Name: roleBinding.Name, Namespace: roleBinding.Namespace}, roleBinding); err != nil { + if !apiError.IsNotFound(err) { + return fmt.Errorf("failed to get existing agent rolebinding %s in namespace %s: %v", roleBinding.Name, roleBinding.Namespace, err) + } + exists = false + } + + // If RoleBinding exists, handle updates or deletion + if exists { + if !has(cr) || !cr.Spec.ArgoCDAgent.Agent.IsEnabled() { + argoutil.LogResourceDeletion(log, roleBinding, "agent rolebinding is being deleted as agent is disabled") + if err := client.Delete(context.TODO(), roleBinding); err != nil { + return fmt.Errorf("failed to delete agent rolebinding %s: %v", roleBinding.Name, err) + } + return nil + } + + // Update RoleBinding if subjects or role ref have changed + if !reflect.DeepEqual(roleBinding.Subjects, expectedSubjects) || + !reflect.DeepEqual(roleBinding.RoleRef, expectedRoleRef) { + + roleBinding.Subjects = expectedSubjects + roleBinding.RoleRef = expectedRoleRef + + argoutil.LogResourceUpdate(log, roleBinding, "agent rolebinding is being updated") + if err := client.Update(context.TODO(), roleBinding); err != nil { + return fmt.Errorf("failed to update agent rolebinding %s: %v", roleBinding.Name, err) + } + } + return nil + } + + // If RoleBinding doesn't exist and agent is disabled, nothing to do + if !has(cr) || !cr.Spec.ArgoCDAgent.Agent.IsEnabled() { + return nil + } + + if err := controllerutil.SetControllerReference(cr, roleBinding, scheme); err != nil { + return fmt.Errorf("failed to set ArgoCD CR %s as owner for rolebinding %s: %w", cr.Name, roleBinding.Name, err) + } + + // Create a fresh RoleBinding object for creation to avoid resourceVersion issues + newRoleBinding := buildRoleBinding(compName, cr) + newRoleBinding.Subjects = expectedSubjects + newRoleBinding.RoleRef = expectedRoleRef + + if err := controllerutil.SetControllerReference(cr, newRoleBinding, scheme); err != nil { + return fmt.Errorf("failed to set ArgoCD CR %s as owner for rolebinding %s: %w", cr.Name, newRoleBinding.Name, err) + } + + argoutil.LogResourceCreation(log, newRoleBinding) + if err := client.Create(context.TODO(), newRoleBinding); err != nil { + return fmt.Errorf("failed to create agent rolebinding %s: %v", newRoleBinding.Name, err) + } + return nil +} + +// ReconcileAgentClusterRoleBinding reconciles a ClusterRoleBinding for the ArgoCD Agent's agent component. +// This function handles the creation, update, and deletion of ClusterRoleBindings based on the agent's enabled state. +func ReconcileAgentClusterRoleBinding(client client.Client, compName string, sa *corev1.ServiceAccount, cr *argoproj.ArgoCD, scheme *runtime.Scheme) error { + clusterRoleBinding := buildClusterRoleBinding(compName, cr) + expectedSubjects := buildSubjects(sa, cr) + expectedRoleRef := buildRoleRef(generateAgentResourceName(cr.Name+"-"+cr.Namespace, compName), "ClusterRole") + + // Check if the ClusterRoleBinding already exists + exists := true + if err := client.Get(context.TODO(), types.NamespacedName{Name: clusterRoleBinding.Name}, clusterRoleBinding); err != nil { + if !apiError.IsNotFound(err) { + return fmt.Errorf("failed to get existing agent clusterrolebinding %s: %v", clusterRoleBinding.Name, err) + } + exists = false + } + + // If ClusterRoleBinding exists, handle updates or deletion + if exists { + if !has(cr) || !cr.Spec.ArgoCDAgent.Agent.IsEnabled() { + argoutil.LogResourceDeletion(log, clusterRoleBinding, "agent clusterrolebinding is being deleted as agent is disabled") + if err := client.Delete(context.TODO(), clusterRoleBinding); err != nil { + return fmt.Errorf("failed to delete agent clusterrolebinding %s: %v", clusterRoleBinding.Name, err) + } + return nil + } + + // Update ClusterRoleBinding if subjects or role ref have changed + if !reflect.DeepEqual(clusterRoleBinding.Subjects, expectedSubjects) || + !reflect.DeepEqual(clusterRoleBinding.RoleRef, expectedRoleRef) { + + clusterRoleBinding.Subjects = expectedSubjects + clusterRoleBinding.RoleRef = expectedRoleRef + + argoutil.LogResourceUpdate(log, clusterRoleBinding, "agent clusterrolebinding is being updated") + if err := client.Update(context.TODO(), clusterRoleBinding); err != nil { + return fmt.Errorf("failed to update agent clusterrolebinding %s: %v", clusterRoleBinding.Name, err) + } + } + return nil + } + + // If ClusterRoleBinding doesn't exist and agent is disabled, nothing to do + if !has(cr) || !cr.Spec.ArgoCDAgent.Agent.IsEnabled() { + return nil + } + + // Create a fresh ClusterRoleBinding object for creation to avoid resourceVersion issues + newClusterRoleBinding := buildClusterRoleBinding(compName, cr) + newClusterRoleBinding.Subjects = expectedSubjects + newClusterRoleBinding.RoleRef = expectedRoleRef + + argoutil.LogResourceCreation(log, newClusterRoleBinding) + if err := client.Create(context.TODO(), newClusterRoleBinding); err != nil { + return fmt.Errorf("failed to create agent clusterrolebinding %s: %v", newClusterRoleBinding.Name, err) + } + return nil +} + +func buildSubjects(sa *corev1.ServiceAccount, cr *argoproj.ArgoCD) []v1.Subject { + return []v1.Subject{ + { + Kind: v1.ServiceAccountKind, + Name: sa.Name, + Namespace: cr.Namespace, + }, + } +} + +func buildRoleRef(name, kind string) v1.RoleRef { + return v1.RoleRef{ + APIGroup: v1.GroupName, + Kind: kind, + Name: name, + } +} + +func buildRoleBinding(compName string, cr *argoproj.ArgoCD) *v1.RoleBinding { + return &v1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, compName), + Labels: buildLabelsForAgent(cr.Name, compName), + Namespace: cr.Namespace, + }, + } +} + +func buildClusterRoleBinding(compName string, cr *argoproj.ArgoCD) *v1.ClusterRoleBinding { + return &v1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, compName), + Labels: buildLabelsForAgent(cr.Name, compName), + }, + } +} diff --git a/controllers/argocdagent/agent/rolebinding_test.go b/controllers/argocdagent/agent/rolebinding_test.go new file mode 100644 index 000000000..6fe16385e --- /dev/null +++ b/controllers/argocdagent/agent/rolebinding_test.go @@ -0,0 +1,601 @@ +// Copyright 2025 ArgoCD Operator Developers +// +// 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 agent + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Test helper functions + +func makeTestServiceAccount() *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: testArgoCDName + "-agent-principal", + Namespace: testNamespace, + }, + } +} + +// Tests for ReconcileAgentRoleBinding + +func TestReconcileAgentRoleBinding_RoleBindingDoesNotExist_AgentDisabled(t *testing.T) { + // Test case: RoleBinding doesn't exist and agent is disabled + // Expected behavior: Should do nothing (no creation, no error) + + cr := makeTestArgoCD(withAgentEnabled(false)) + sa := makeTestServiceAccount() + + resObjs := []client.Object{cr, sa} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentRoleBinding(cl, testAgentCompName, sa, cr, sch) + assert.NoError(t, err) + + // Verify RoleBinding was not created + roleBinding := &v1.RoleBinding{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, roleBinding) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentRoleBinding_RoleBindingDoesNotExist_AgentEnabled(t *testing.T) { + // Test case: RoleBinding doesn't exist and agent is enabled + // Expected behavior: Should create the RoleBinding with expected subjects and roleRef + + cr := makeTestArgoCD(withAgentEnabled(true)) + sa := makeTestServiceAccount() + + resObjs := []client.Object{cr, sa} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentRoleBinding(cl, testAgentCompName, sa, cr, sch) + assert.NoError(t, err) + + // Verify RoleBinding was created + roleBinding := &v1.RoleBinding{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, roleBinding) + assert.NoError(t, err) + + // Verify RoleBinding has expected metadata + assert.Equal(t, generateAgentResourceName(cr.Name, testAgentCompName), roleBinding.Name) + assert.Equal(t, cr.Namespace, roleBinding.Namespace) + assert.Equal(t, buildLabelsForAgent(cr.Name, testAgentCompName), roleBinding.Labels) + + // Verify RoleBinding has expected subjects + expectedSubjects := buildSubjects(sa, cr) + assert.Equal(t, expectedSubjects, roleBinding.Subjects) + + // Verify RoleBinding has expected roleRef + expectedRoleRef := buildRoleRef(generateAgentResourceName(cr.Name, testAgentCompName), "Role") + assert.Equal(t, expectedRoleRef, roleBinding.RoleRef) + + // Verify owner reference is set + assert.Len(t, roleBinding.OwnerReferences, 1) + assert.Equal(t, cr.Name, roleBinding.OwnerReferences[0].Name) + assert.Equal(t, "ArgoCD", roleBinding.OwnerReferences[0].Kind) +} + +func TestReconcileAgentRoleBinding_RoleBindingExists_AgentDisabled(t *testing.T) { + // Test case: RoleBinding exists and agent is disabled + // Expected behavior: Should delete the RoleBinding + + cr := makeTestArgoCD(withAgentEnabled(false)) + sa := makeTestServiceAccount() + + // Create existing RoleBinding + existingRoleBinding := &v1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Subjects: buildSubjects(sa, cr), + RoleRef: buildRoleRef(generateAgentResourceName(cr.Name, testAgentCompName), "Role"), + } + + resObjs := []client.Object{cr, sa, existingRoleBinding} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentRoleBinding(cl, testAgentCompName, sa, cr, sch) + assert.NoError(t, err) + + // Verify RoleBinding was deleted + roleBinding := &v1.RoleBinding{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, roleBinding) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentRoleBinding_RoleBindingExists_AgentEnabled_SameConfiguration(t *testing.T) { + // Test case: RoleBinding exists, agent is enabled, and configuration is the same + // Expected behavior: Should do nothing (no update) + + cr := makeTestArgoCD(withAgentEnabled(true)) + sa := makeTestServiceAccount() + + expectedSubjects := buildSubjects(sa, cr) + expectedRoleRef := buildRoleRef(generateAgentResourceName(cr.Name, testAgentCompName), "Role") + + // Create existing RoleBinding with correct configuration + existingRoleBinding := &v1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Subjects: expectedSubjects, + RoleRef: expectedRoleRef, + } + + resObjs := []client.Object{cr, sa, existingRoleBinding} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentRoleBinding(cl, testAgentCompName, sa, cr, sch) + assert.NoError(t, err) + + // Verify RoleBinding still exists with same configuration + roleBinding := &v1.RoleBinding{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, roleBinding) + assert.NoError(t, err) + assert.Equal(t, expectedSubjects, roleBinding.Subjects) + assert.Equal(t, expectedRoleRef, roleBinding.RoleRef) +} + +func TestReconcileAgentRoleBinding_RoleBindingExists_AgentEnabled_DifferentSubjects(t *testing.T) { + // Test case: RoleBinding exists, agent is enabled, but subjects are different + // Expected behavior: Should update the RoleBinding with new subjects + + cr := makeTestArgoCD(withAgentEnabled(true)) + sa := makeTestServiceAccount() + + // Create existing RoleBinding with different subjects + oldSubjects := []v1.Subject{ + { + Kind: v1.ServiceAccountKind, + Name: "different-sa", + Namespace: cr.Namespace, + }, + } + expectedRoleRef := buildRoleRef(generateAgentResourceName(cr.Name, testAgentCompName), "Role") + + existingRoleBinding := &v1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Subjects: oldSubjects, + RoleRef: expectedRoleRef, + } + + resObjs := []client.Object{cr, sa, existingRoleBinding} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentRoleBinding(cl, testAgentCompName, sa, cr, sch) + assert.NoError(t, err) + + // Verify RoleBinding was updated with expected subjects + roleBinding := &v1.RoleBinding{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, roleBinding) + assert.NoError(t, err) + + expectedSubjects := buildSubjects(sa, cr) + assert.Equal(t, expectedSubjects, roleBinding.Subjects) + assert.NotEqual(t, oldSubjects, roleBinding.Subjects) +} + +func TestReconcileAgentRoleBinding_RoleBindingExists_AgentEnabled_DifferentRoleRef(t *testing.T) { + // Test case: RoleBinding exists, agent is enabled, but roleRef is different + // Expected behavior: Should update the RoleBinding with new roleRef + + cr := makeTestArgoCD(withAgentEnabled(true)) + sa := makeTestServiceAccount() + + expectedSubjects := buildSubjects(sa, cr) + + // Create existing RoleBinding with different roleRef + oldRoleRef := v1.RoleRef{ + APIGroup: v1.GroupName, + Kind: "Role", + Name: "different-role", + } + + existingRoleBinding := &v1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Subjects: expectedSubjects, + RoleRef: oldRoleRef, + } + + resObjs := []client.Object{cr, sa, existingRoleBinding} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentRoleBinding(cl, testAgentCompName, sa, cr, sch) + assert.NoError(t, err) + + // Verify RoleBinding was updated with expected roleRef + roleBinding := &v1.RoleBinding{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, roleBinding) + assert.NoError(t, err) + + expectedRoleRef := buildRoleRef(generateAgentResourceName(cr.Name, testAgentCompName), "Role") + assert.Equal(t, expectedRoleRef, roleBinding.RoleRef) + assert.NotEqual(t, oldRoleRef, roleBinding.RoleRef) +} + +func TestReconcileAgentRoleBinding_RoleBindingExists_AgentNotSet(t *testing.T) { + // Test case: RoleBinding exists but agent is not set (nil) + // Expected behavior: Should delete the RoleBinding + + cr := makeTestArgoCD() // No agent configuration + sa := makeTestServiceAccount() + + // Create existing RoleBinding + existingRoleBinding := &v1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Subjects: buildSubjects(sa, cr), + RoleRef: buildRoleRef(generateAgentResourceName(cr.Name, testAgentCompName), "Role"), + } + + resObjs := []client.Object{cr, sa, existingRoleBinding} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentRoleBinding(cl, testAgentCompName, sa, cr, sch) + assert.NoError(t, err) + + // Verify RoleBinding was deleted (since agent is not enabled by default) + roleBinding := &v1.RoleBinding{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, roleBinding) + assert.True(t, errors.IsNotFound(err)) +} + +// Tests for ReconcileAgentClusterRoleBinding + +func TestReconcileAgentClusterRoleBinding_ClusterRoleBindingDoesNotExist_AgentDisabled(t *testing.T) { + // Test case: ClusterRoleBinding doesn't exist and agent is disabled + // Expected behavior: Should do nothing (no creation, no error) + + cr := makeTestArgoCD(withAgentEnabled(false)) + sa := makeTestServiceAccount() + + resObjs := []client.Object{cr, sa} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentClusterRoleBinding(cl, testAgentCompName, sa, cr, sch) + assert.NoError(t, err) + + // Verify ClusterRoleBinding was not created + clusterRoleBinding := &v1.ClusterRoleBinding{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + }, clusterRoleBinding) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentClusterRoleBinding_ClusterRoleBindingDoesNotExist_AgentEnabled(t *testing.T) { + // Test case: ClusterRoleBinding doesn't exist and agent is enabled + // Expected behavior: Should create the ClusterRoleBinding with expected subjects and roleRef + + cr := makeTestArgoCD(withAgentEnabled(true)) + sa := makeTestServiceAccount() + + resObjs := []client.Object{cr, sa} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentClusterRoleBinding(cl, testAgentCompName, sa, cr, sch) + assert.NoError(t, err) + + // Verify ClusterRoleBinding was created + clusterRoleBinding := &v1.ClusterRoleBinding{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + }, clusterRoleBinding) + assert.NoError(t, err) + + // Verify ClusterRoleBinding has expected metadata + assert.Equal(t, generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), clusterRoleBinding.Name) + assert.Equal(t, buildLabelsForAgent(cr.Name, testAgentCompName), clusterRoleBinding.Labels) + + // Verify ClusterRoleBinding has expected subjects + expectedSubjects := buildSubjects(sa, cr) + assert.Equal(t, expectedSubjects, clusterRoleBinding.Subjects) + + // Verify ClusterRoleBinding has expected roleRef + expectedRoleRef := buildRoleRef(generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), "ClusterRole") + assert.Equal(t, expectedRoleRef, clusterRoleBinding.RoleRef) + + // Verify no owner reference is set for ClusterRoleBinding (as expected from the code) + assert.Len(t, clusterRoleBinding.OwnerReferences, 0) +} + +func TestReconcileAgentClusterRoleBinding_ClusterRoleBindingExists_AgentDisabled(t *testing.T) { + // Test case: ClusterRoleBinding exists and agent is disabled + // Expected behavior: Should delete the ClusterRoleBinding + + cr := makeTestArgoCD(withAgentEnabled(false)) + sa := makeTestServiceAccount() + + // Create existing ClusterRoleBinding + existingClusterRoleBinding := &v1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Subjects: buildSubjects(sa, cr), + RoleRef: buildRoleRef(generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), "ClusterRole"), + } + + resObjs := []client.Object{cr, sa, existingClusterRoleBinding} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentClusterRoleBinding(cl, testAgentCompName, sa, cr, sch) + assert.NoError(t, err) + + // Verify ClusterRoleBinding was deleted + clusterRoleBinding := &v1.ClusterRoleBinding{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + }, clusterRoleBinding) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentClusterRoleBinding_ClusterRoleBindingExists_AgentEnabled_SameConfiguration(t *testing.T) { + // Test case: ClusterRoleBinding exists, agent is enabled, and configuration is the same + // Expected behavior: Should do nothing (no update) + + cr := makeTestArgoCD(withAgentEnabled(true)) + sa := makeTestServiceAccount() + + expectedSubjects := buildSubjects(sa, cr) + expectedRoleRef := buildRoleRef(generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), "ClusterRole") + + // Create existing ClusterRoleBinding with correct configuration + existingClusterRoleBinding := &v1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Subjects: expectedSubjects, + RoleRef: expectedRoleRef, + } + + resObjs := []client.Object{cr, sa, existingClusterRoleBinding} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentClusterRoleBinding(cl, testAgentCompName, sa, cr, sch) + assert.NoError(t, err) + + // Verify ClusterRoleBinding still exists with same configuration + clusterRoleBinding := &v1.ClusterRoleBinding{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + }, clusterRoleBinding) + assert.NoError(t, err) + assert.Equal(t, expectedSubjects, clusterRoleBinding.Subjects) + assert.Equal(t, expectedRoleRef, clusterRoleBinding.RoleRef) +} + +func TestReconcileAgentClusterRoleBinding_ClusterRoleBindingExists_AgentEnabled_DifferentSubjects(t *testing.T) { + // Test case: ClusterRoleBinding exists, agent is enabled, but subjects are different + // Expected behavior: Should update the ClusterRoleBinding with new subjects + + cr := makeTestArgoCD(withAgentEnabled(true)) + sa := makeTestServiceAccount() + + // Create existing ClusterRoleBinding with different subjects + oldSubjects := []v1.Subject{ + { + Kind: v1.ServiceAccountKind, + Name: "different-sa", + Namespace: cr.Namespace, + }, + } + expectedRoleRef := buildRoleRef(generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), "ClusterRole") + + existingClusterRoleBinding := &v1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Subjects: oldSubjects, + RoleRef: expectedRoleRef, + } + + resObjs := []client.Object{cr, sa, existingClusterRoleBinding} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentClusterRoleBinding(cl, testAgentCompName, sa, cr, sch) + assert.NoError(t, err) + + // Verify ClusterRoleBinding was updated with expected subjects + clusterRoleBinding := &v1.ClusterRoleBinding{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + }, clusterRoleBinding) + assert.NoError(t, err) + + expectedSubjects := buildSubjects(sa, cr) + assert.Equal(t, expectedSubjects, clusterRoleBinding.Subjects) + assert.NotEqual(t, oldSubjects, clusterRoleBinding.Subjects) +} + +func TestReconcileAgentClusterRoleBinding_ClusterRoleBindingExists_AgentEnabled_DifferentRoleRef(t *testing.T) { + // Test case: ClusterRoleBinding exists, agent is enabled, but roleRef is different + // Expected behavior: Should update the ClusterRoleBinding with new roleRef + + cr := makeTestArgoCD(withAgentEnabled(true)) + sa := makeTestServiceAccount() + + expectedSubjects := buildSubjects(sa, cr) + + // Create existing ClusterRoleBinding with different roleRef + oldRoleRef := v1.RoleRef{ + APIGroup: v1.GroupName, + Kind: "ClusterRole", + Name: "different-cluster-role", + } + + existingClusterRoleBinding := &v1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Subjects: expectedSubjects, + RoleRef: oldRoleRef, + } + + resObjs := []client.Object{cr, sa, existingClusterRoleBinding} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentClusterRoleBinding(cl, testAgentCompName, sa, cr, sch) + assert.NoError(t, err) + + // Verify ClusterRoleBinding was updated with expected roleRef + clusterRoleBinding := &v1.ClusterRoleBinding{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + }, clusterRoleBinding) + assert.NoError(t, err) + + expectedRoleRef := buildRoleRef(generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), "ClusterRole") + assert.Equal(t, expectedRoleRef, clusterRoleBinding.RoleRef) + assert.NotEqual(t, oldRoleRef, clusterRoleBinding.RoleRef) +} + +func TestReconcileAgentClusterRoleBinding_ClusterRoleBindingExists_AgentNotSet(t *testing.T) { + // Test case: ClusterRoleBinding exists but agent is not set (nil) + // Expected behavior: Should delete the ClusterRoleBinding + + cr := makeTestArgoCD() // No agent configuration + sa := makeTestServiceAccount() + + // Create existing ClusterRoleBinding + existingClusterRoleBinding := &v1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Subjects: buildSubjects(sa, cr), + RoleRef: buildRoleRef(generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), "ClusterRole"), + } + + resObjs := []client.Object{cr, sa, existingClusterRoleBinding} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentClusterRoleBinding(cl, testAgentCompName, sa, cr, sch) + assert.NoError(t, err) + + // Verify ClusterRoleBinding was deleted (since agent is not enabled by default) + clusterRoleBinding := &v1.ClusterRoleBinding{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + }, clusterRoleBinding) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentRoleBinding_RoleBindingDoesNotExist_AgentNotSet(t *testing.T) { + // Test case: RoleBinding doesn't exist and ArgoCDAgent is not set (nil) + // Expected behavior: Should do nothing since agent is effectively disabled + + cr := makeTestArgoCD() // No agent configuration + sa := makeTestServiceAccount() + + resObjs := []client.Object{cr, sa} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentRoleBinding(cl, testAgentCompName, sa, cr, sch) + assert.NoError(t, err) + + // Verify RoleBinding was not created + roleBinding := &v1.RoleBinding{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, roleBinding) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentClusterRoleBinding_ClusterRoleBindingDoesNotExist_AgentNotSet(t *testing.T) { + // Test case: ClusterRoleBinding doesn't exist and ArgoCDAgent is not set (nil) + // Expected behavior: Should do nothing since agent is effectively disabled + + cr := makeTestArgoCD() // No agent configuration + sa := makeTestServiceAccount() + + resObjs := []client.Object{cr, sa} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentClusterRoleBinding(cl, testAgentCompName, sa, cr, sch) + assert.NoError(t, err) + + // Verify ClusterRoleBinding was not created + clusterRoleBinding := &v1.ClusterRoleBinding{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name+"-"+cr.Namespace, testAgentCompName), + }, clusterRoleBinding) + assert.True(t, errors.IsNotFound(err)) +} diff --git a/controllers/argocdagent/agent/service.go b/controllers/argocdagent/agent/service.go new file mode 100644 index 000000000..d21f28f4e --- /dev/null +++ b/controllers/argocdagent/agent/service.go @@ -0,0 +1,211 @@ +// Copyright 2025 ArgoCD Operator Developers +// +// 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 agent + +import ( + "context" + "fmt" + "reflect" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + argoproj "github.com/argoproj-labs/argocd-operator/api/v1beta1" + "github.com/argoproj-labs/argocd-operator/controllers/argoutil" +) + +const ( + // AgentMetricsServicePortName is the name of the metrics port + AgentMetricsServicePortName = "metrics" + // AgentMetricsServicePort is the external port for the agent metrics service + AgentMetricsServicePort = 8181 + // AgentMetricsServiceTargetPort is the target port for the agent metrics service + AgentMetricsServiceTargetPort = 8181 + // AgentHealthzServicePortName is the name of the healthz port + AgentHealthzServicePortName = "healthz" + // AgentHealthzServicePort is the external port for the agent healthz service + AgentHealthzServicePort = 8002 + // AgentHealthzServiceTargetPort is the target port for the agent healthz service + AgentHealthzServiceTargetPort = 8002 +) + +// ReconcileAgentMetricsService reconciles the agent metrics service for the ArgoCD agent. +// It creates, updates, or deletes the metrics service based on the agent configuration. +func ReconcileAgentMetricsService(client client.Client, compName string, cr *argoproj.ArgoCD, scheme *runtime.Scheme) error { + + service := buildService(generateAgentResourceName(cr.Name, compName)+"-metrics", compName, cr) + expectedSpec := buildAgentMetricsServiceSpec(compName, cr) + + // Check if the metrics service already exists in the cluster + exists := true + if err := argoutil.FetchObject(client, cr.Namespace, service.Name, service); err != nil { + if !errors.IsNotFound(err) { + return fmt.Errorf("failed to get existing agent metrics service %s in namespace %s: %v", service.Name, cr.Namespace, err) + } + exists = false + } + + // If metrics service exists, handle updates or deletion + if exists { + if !has(cr) || !cr.Spec.ArgoCDAgent.Agent.IsEnabled() { + argoutil.LogResourceDeletion(log, service, "agent metrics service is being deleted as agent is disabled") + if err := client.Delete(context.TODO(), service); err != nil { + return fmt.Errorf("failed to delete agent metrics service %s: %v", service.Name, err) + } + return nil + } + + if !reflect.DeepEqual(service.Spec.Ports, expectedSpec.Ports) || + !reflect.DeepEqual(service.Spec.Selector, expectedSpec.Selector) || + !reflect.DeepEqual(service.Spec.Type, expectedSpec.Type) { + + service.Spec.Type = expectedSpec.Type + service.Spec.Ports = expectedSpec.Ports + service.Spec.Selector = expectedSpec.Selector + + argoutil.LogResourceUpdate(log, service, "updating agent metrics service spec") + if err := client.Update(context.TODO(), service); err != nil { + return fmt.Errorf("failed to update agent metrics service %s: %v", service.Name, err) + } + } + return nil + } + + // If metrics service doesn't exist and agent is disabled, nothing to do + if !has(cr) || !cr.Spec.ArgoCDAgent.Agent.IsEnabled() { + return nil + } + + if err := controllerutil.SetControllerReference(cr, service, scheme); err != nil { + return fmt.Errorf("failed to set ArgoCD CR %s as owner for service %s: %w", cr.Name, service.Name, err) + } + + service.Spec.Type = expectedSpec.Type + service.Spec.Ports = expectedSpec.Ports + service.Spec.Selector = expectedSpec.Selector + + argoutil.LogResourceCreation(log, service) + if err := client.Create(context.TODO(), service); err != nil { + return fmt.Errorf("failed to create agent metrics service %s: %v", service.Name, err) + } + return nil +} + +// ReconcileAgentHealthzService reconciles the agent healthz service for the ArgoCD agent. +// It creates, updates, or deletes the healthz service based on the agent configuration. +func ReconcileAgentHealthzService(client client.Client, compName string, cr *argoproj.ArgoCD, scheme *runtime.Scheme) error { + + service := buildService(generateAgentResourceName(cr.Name, compName)+"-healthz", compName, cr) + expectedSpec := buildAgentHealthzServiceSpec(compName, cr) + + // Check if the healthz service already exists in the cluster + exists := true + if err := argoutil.FetchObject(client, cr.Namespace, service.Name, service); err != nil { + if !errors.IsNotFound(err) { + return fmt.Errorf("failed to get existing agent healthz service %s in namespace %s: %v", service.Name, cr.Namespace, err) + } + exists = false + } + + // If healthz service exists, handle updates or deletion + if exists { + if !has(cr) || !cr.Spec.ArgoCDAgent.Agent.IsEnabled() { + argoutil.LogResourceDeletion(log, service, "agent healthz service is being deleted as agent is disabled") + if err := client.Delete(context.TODO(), service); err != nil { + return fmt.Errorf("failed to delete agent healthz service %s: %v", service.Name, err) + } + return nil + } + + if !reflect.DeepEqual(service.Spec.Ports, expectedSpec.Ports) || + !reflect.DeepEqual(service.Spec.Selector, expectedSpec.Selector) || + !reflect.DeepEqual(service.Spec.Type, expectedSpec.Type) { + + service.Spec.Type = expectedSpec.Type + service.Spec.Ports = expectedSpec.Ports + service.Spec.Selector = expectedSpec.Selector + + argoutil.LogResourceUpdate(log, service, "updating agent healthz service spec") + if err := client.Update(context.TODO(), service); err != nil { + return fmt.Errorf("failed to update agent healthz service %s: %v", service.Name, err) + } + } + return nil + } + + // If healthz service doesn't exist and agent is disabled, nothing to do + if !has(cr) || !cr.Spec.ArgoCDAgent.Agent.IsEnabled() { + return nil + } + + if err := controllerutil.SetControllerReference(cr, service, scheme); err != nil { + return fmt.Errorf("failed to set ArgoCD CR %s as owner for service %s: %w", cr.Name, service.Name, err) + } + + service.Spec.Type = expectedSpec.Type + service.Spec.Ports = expectedSpec.Ports + service.Spec.Selector = expectedSpec.Selector + + argoutil.LogResourceCreation(log, service) + if err := client.Create(context.TODO(), service); err != nil { + return fmt.Errorf("failed to create agent healthz service %s: %v", service.Name, err) + } + return nil +} + +func buildAgentMetricsServiceSpec(compName string, cr *argoproj.ArgoCD) corev1.ServiceSpec { + return corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: AgentMetricsServicePortName, + Port: AgentMetricsServicePort, + TargetPort: intstr.FromInt(AgentMetricsServiceTargetPort), + Protocol: corev1.ProtocolTCP, + }, + }, + Selector: buildLabelsForAgent(cr.Name, compName), + Type: corev1.ServiceTypeClusterIP, + } +} + +func buildAgentHealthzServiceSpec(compName string, cr *argoproj.ArgoCD) corev1.ServiceSpec { + return corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: AgentHealthzServicePortName, + Port: AgentHealthzServicePort, + TargetPort: intstr.FromInt(AgentHealthzServiceTargetPort), + Protocol: corev1.ProtocolTCP, + }, + }, + Selector: buildLabelsForAgent(cr.Name, compName), + Type: corev1.ServiceTypeClusterIP, + } +} + +func buildService(name, compName string, cr *argoproj.ArgoCD) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, compName), + }, + } +} diff --git a/controllers/argocdagent/agent/service_account.go b/controllers/argocdagent/agent/service_account.go new file mode 100644 index 000000000..de4551b8d --- /dev/null +++ b/controllers/argocdagent/agent/service_account.go @@ -0,0 +1,104 @@ +// Copyright 2025 ArgoCD Operator Developers +// +// 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 agent + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + logr "sigs.k8s.io/controller-runtime/pkg/log" + + argoproj "github.com/argoproj-labs/argocd-operator/api/v1beta1" + "github.com/argoproj-labs/argocd-operator/common" + "github.com/argoproj-labs/argocd-operator/controllers/argoutil" +) + +var log = logr.Log.WithName("controller_agent") + +// ReconcileAgentServiceAccount reconciles the service account for the ArgoCD agent component. +// It handles creation, deletion, and updates of the service account based on the agent configuration. +func ReconcileAgentServiceAccount(client client.Client, compName string, cr *argoproj.ArgoCD, scheme *runtime.Scheme) (*corev1.ServiceAccount, error) { + sa := buildServiceAccount(compName, cr) + + // Check if the service account already exists + exists := true + if err := argoutil.FetchObject(client, cr.Namespace, sa.Name, sa); err != nil { + if !errors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get existing agent service account %s in namespace %s: %v", sa.Name, cr.Namespace, err) + } + exists = false + } + + if exists { + // If service account exists but agent is disabled, delete it + if !has(cr) || !cr.Spec.ArgoCDAgent.Agent.IsEnabled() { + argoutil.LogResourceDeletion(log, sa, "agent service account is being deleted as agent is disabled") + if err := client.Delete(context.TODO(), sa); err != nil { + return nil, fmt.Errorf("failed to delete agent service account %s: %v", sa.Name, err) + } + return sa, nil + } + // Service account exists and agent is enabled, nothing to do + return sa, nil + } + + // If service account doesn't exist and agent is disabled, nothing to do + if !has(cr) || !cr.Spec.ArgoCDAgent.Agent.IsEnabled() { + return sa, nil + } + + if err := controllerutil.SetControllerReference(cr, sa, scheme); err != nil { + return nil, fmt.Errorf("failed to set ArgoCD CR %s as owner for service account %s: %w", cr.Name, sa.Name, err) + } + + // Create the service account since it doesn't exist and agent is enabled + argoutil.LogResourceCreation(log, sa) + if err := client.Create(context.TODO(), sa); err != nil { + return nil, fmt.Errorf("failed to create agent service account %s: %v", sa.Name, err) + } + return sa, nil +} + +func buildServiceAccount(compName string, cr *argoproj.ArgoCD) *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, compName), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, compName), + }, + } +} + +// generateAgentResourceName creates a standardized resource name for ArgoCD agent components +// by combining the ArgoCD CR name with the component name. +func generateAgentResourceName(crName, compName string) string { + return fmt.Sprintf("%s-agent-%s", crName, compName) +} + +func buildLabelsForAgent(crName, compName string) map[string]string { + return map[string]string{ + common.ArgoCDKeyComponent: compName, + common.ArgoCDKeyName: generateAgentResourceName(crName, compName), + common.ArgoCDKeyPartOf: "argocd-agent", + common.ArgoCDKeyManagedBy: crName, + } +} diff --git a/controllers/argocdagent/agent/service_account_test.go b/controllers/argocdagent/agent/service_account_test.go new file mode 100644 index 000000000..5b82f4a90 --- /dev/null +++ b/controllers/argocdagent/agent/service_account_test.go @@ -0,0 +1,261 @@ +// Copyright 2025 ArgoCD Operator Developers +// +// 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 agent + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + argoproj "github.com/argoproj-labs/argocd-operator/api/v1beta1" +) + +// Test constants +const ( + testAgentCompName = "agent" + testNamespace = "argocd" + testArgoCDName = "argocd" +) + +// Test helper functions +type argoCDOpt func(*argoproj.ArgoCD) + +func makeTestArgoCD(opts ...argoCDOpt) *argoproj.ArgoCD { + a := &argoproj.ArgoCD{ + ObjectMeta: metav1.ObjectMeta{ + Name: testArgoCDName, + Namespace: testNamespace, + }, + } + for _, o := range opts { + o(a) + } + return a +} + +func withAgentEnabled(enabled bool) argoCDOpt { + return func(a *argoproj.ArgoCD) { + if a.Spec.ArgoCDAgent == nil { + a.Spec.ArgoCDAgent = &argoproj.ArgoCDAgentSpec{} + } + if a.Spec.ArgoCDAgent.Agent == nil { + a.Spec.ArgoCDAgent.Agent = &argoproj.AgentSpec{} + } + a.Spec.ArgoCDAgent.Agent.Enabled = &enabled + } +} + +func makeTestReconcilerScheme() *runtime.Scheme { + s := scheme.Scheme + _ = argoproj.AddToScheme(s) + return s +} + +func makeTestReconcilerClient(sch *runtime.Scheme, resObjs []client.Object) client.Client { + client := fake.NewClientBuilder().WithScheme(sch) + if len(resObjs) > 0 { + client = client.WithObjects(resObjs...) + } + return client.Build() +} + +// TestReconcileAgentServiceAccount tests + +func TestReconcileAgentServiceAccount_ServiceAccountDoesNotExist_AgentDisabled(t *testing.T) { + // Test case: ServiceAccount doesn't exist and agent is disabled + // Expected behavior: Should do nothing (no creation, no error) + + cr := makeTestArgoCD(withAgentEnabled(false)) + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + sa, err := ReconcileAgentServiceAccount(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, sa) + + // Verify ServiceAccount was not created + retrievedSA := &corev1.ServiceAccount{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, retrievedSA) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentServiceAccount_ServiceAccountDoesNotExist_AgentEnabled(t *testing.T) { + // Test case: ServiceAccount doesn't exist and agent is enabled + // Expected behavior: Should create the ServiceAccount + + cr := makeTestArgoCD(withAgentEnabled(true)) + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + sa, err := ReconcileAgentServiceAccount(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, sa) + + // Verify ServiceAccount was created + retrievedSA := &corev1.ServiceAccount{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, retrievedSA) + assert.NoError(t, err) + + // Verify ServiceAccount has expected metadata + assert.Equal(t, generateAgentResourceName(cr.Name, testAgentCompName), retrievedSA.Name) + assert.Equal(t, cr.Namespace, retrievedSA.Namespace) + assert.Equal(t, buildLabelsForAgent(cr.Name, testAgentCompName), retrievedSA.Labels) + + // Verify owner reference is set + assert.Len(t, retrievedSA.OwnerReferences, 1) + assert.Equal(t, cr.Name, retrievedSA.OwnerReferences[0].Name) + assert.Equal(t, "ArgoCD", retrievedSA.OwnerReferences[0].Kind) +} + +func TestReconcileAgentServiceAccount_ServiceAccountExists_AgentDisabled(t *testing.T) { + // Test case: ServiceAccount exists and agent is disabled + // Expected behavior: Should delete the ServiceAccount + + cr := makeTestArgoCD(withAgentEnabled(false)) + + // Create existing ServiceAccount + existingSA := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + } + + resObjs := []client.Object{cr, existingSA} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + sa, err := ReconcileAgentServiceAccount(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, sa) + + // Verify ServiceAccount was deleted + retrievedSA := &corev1.ServiceAccount{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, retrievedSA) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentServiceAccount_ServiceAccountExists_AgentEnabled(t *testing.T) { + // Test case: ServiceAccount exists and agent is enabled + // Expected behavior: Should return the existing ServiceAccount without modification + + cr := makeTestArgoCD(withAgentEnabled(true)) + + // Create existing ServiceAccount + existingSA := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + } + + resObjs := []client.Object{cr, existingSA} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + sa, err := ReconcileAgentServiceAccount(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, sa) + + // Verify ServiceAccount still exists + retrievedSA := &corev1.ServiceAccount{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, retrievedSA) + assert.NoError(t, err) + assert.Equal(t, generateAgentResourceName(cr.Name, testAgentCompName), retrievedSA.Name) + assert.Equal(t, cr.Namespace, retrievedSA.Namespace) + assert.Equal(t, buildLabelsForAgent(cr.Name, testAgentCompName), retrievedSA.Labels) +} + +func TestReconcileAgentServiceAccount_ServiceAccountExists_AgentNotSet(t *testing.T) { + // Test case: ServiceAccount exists but agent is not set (nil) + // Expected behavior: Should delete the ServiceAccount + + cr := makeTestArgoCD() // No agent configuration + + // Create existing ServiceAccount + existingSA := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + } + + resObjs := []client.Object{cr, existingSA} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + sa, err := ReconcileAgentServiceAccount(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, sa) + + // Verify ServiceAccount was deleted (since agent is not enabled by default) + retrievedSA := &corev1.ServiceAccount{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, retrievedSA) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentServiceAccount_ServiceAccountDoesNotExist_AgentNotSet(t *testing.T) { + // Test case: ServiceAccount doesn't exist and agent is not set (nil) + // Expected behavior: Should do nothing (no creation, no error) + + cr := makeTestArgoCD() // No agent configuration + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + sa, err := ReconcileAgentServiceAccount(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + assert.NotNil(t, sa) + + // Verify ServiceAccount was not created + retrievedSA := &corev1.ServiceAccount{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName), + Namespace: cr.Namespace, + }, retrievedSA) + assert.True(t, errors.IsNotFound(err)) +} diff --git a/controllers/argocdagent/agent/service_test.go b/controllers/argocdagent/agent/service_test.go new file mode 100644 index 000000000..6cbcedbc1 --- /dev/null +++ b/controllers/argocdagent/agent/service_test.go @@ -0,0 +1,522 @@ +// Copyright 2025 ArgoCD Operator Developers +// +// 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 agent + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Metrics Service Tests + +func TestReconcileAgentMetricsService_ServiceDoesNotExist_AgentDisabled(t *testing.T) { + // Test case: Service doesn't exist and agent is disabled + // Expected behavior: Should do nothing (no creation, no error) + + cr := makeTestArgoCD(withAgentEnabled(false)) + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentMetricsService(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + + // Verify Service was not created + svc := &corev1.Service{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-metrics"), + Namespace: cr.Namespace, + }, svc) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentMetricsService_ServiceDoesNotExist_AgentEnabled(t *testing.T) { + // Test case: Service doesn't exist and agent is enabled + // Expected behavior: Should create the Service with expected spec + + cr := makeTestArgoCD(withAgentEnabled(true)) + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentMetricsService(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + + // Verify Service was created + svc := &corev1.Service{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-metrics"), + Namespace: cr.Namespace, + }, svc) + assert.NoError(t, err) + + // Verify Service has expected metadata + expectedName := generateAgentResourceName(cr.Name, testAgentCompName+"-metrics") + assert.Equal(t, expectedName, svc.Name) + assert.Equal(t, cr.Namespace, svc.Namespace) + assert.Equal(t, buildLabelsForAgent(cr.Name, testAgentCompName), svc.Labels) + + // Verify Service has correct metrics port configuration + assert.Len(t, svc.Spec.Ports, 1) + metricsPort := svc.Spec.Ports[0] + assert.Equal(t, "metrics", metricsPort.Name) + assert.Equal(t, int32(8181), metricsPort.Port) + assert.Equal(t, intstr.FromInt(8181), metricsPort.TargetPort) + assert.Equal(t, corev1.ProtocolTCP, metricsPort.Protocol) + + // Verify Service type is ClusterIP + assert.Equal(t, corev1.ServiceTypeClusterIP, svc.Spec.Type) + + // Verify owner reference is set + assert.Len(t, svc.OwnerReferences, 1) + assert.Equal(t, cr.Name, svc.OwnerReferences[0].Name) + assert.Equal(t, "ArgoCD", svc.OwnerReferences[0].Kind) +} + +func TestReconcileAgentMetricsService_ServiceExists_AgentDisabled(t *testing.T) { + // Test case: Service exists and agent is disabled + // Expected behavior: Should delete the Service + + cr := makeTestArgoCD(withAgentEnabled(false)) + + // Create existing Service + expectedSvc := buildService(generateAgentResourceName(cr.Name, testAgentCompName)+"-metrics", testAgentCompName, cr) + expectedSvc.Spec = buildAgentMetricsServiceSpec(testAgentCompName, cr) + existingSvc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-metrics"), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Spec: expectedSvc.Spec, + } + + resObjs := []client.Object{cr, existingSvc} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentMetricsService(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + + // Verify Service was deleted + svc := &corev1.Service{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-metrics"), + Namespace: cr.Namespace, + }, svc) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentMetricsService_ServiceExists_AgentEnabled_SameSpec(t *testing.T) { + // Test case: Metrics service exists, agent is enabled, and spec is the same + // Expected behavior: Should do nothing (no update) + + cr := makeTestArgoCD(withAgentEnabled(true)) + + expectedSvc := buildService(generateAgentResourceName(cr.Name, testAgentCompName)+"-metrics", testAgentCompName, cr) + expectedSvc.Spec = buildAgentMetricsServiceSpec(testAgentCompName, cr) + existingSvc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-metrics"), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Spec: expectedSvc.Spec, + } + + resObjs := []client.Object{cr, existingSvc} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentMetricsService(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + + // Verify Service still exists with same spec + svc := &corev1.Service{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-metrics"), + Namespace: cr.Namespace, + }, svc) + assert.NoError(t, err) + assert.Equal(t, expectedSvc.Spec.Type, svc.Spec.Type) + assert.Equal(t, expectedSvc.Spec.Ports, svc.Spec.Ports) + assert.Equal(t, expectedSvc.Spec.Selector, svc.Spec.Selector) +} + +func TestReconcileAgentMetricsService_ServiceExists_AgentEnabled_DifferentSpec(t *testing.T) { + // Test case: Metrics service exists, agent is enabled, but spec is different + // Expected behavior: Should update the metrics service with expected spec + + cr := makeTestArgoCD(withAgentEnabled(true)) + + // Create existing Service with different spec + differentSpec := corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, // Different from expected ClusterIP + Ports: []corev1.ServicePort{ + { + Name: "http", // Different port name + Port: 80, // Different port + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromInt(8080), // Different target port + }, + }, + Selector: map[string]string{ + "app": "different-app", // Different selector + }, + } + + existingSvc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-metrics"), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Spec: differentSpec, + } + + resObjs := []client.Object{cr, existingSvc} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentMetricsService(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + + // Verify Service was updated with expected spec + svc := &corev1.Service{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-metrics"), + Namespace: cr.Namespace, + }, svc) + assert.NoError(t, err) + + expectedSvc := buildService(generateAgentResourceName(cr.Name, testAgentCompName)+"-metrics", testAgentCompName, cr) + expectedSvc.Spec = buildAgentMetricsServiceSpec(testAgentCompName, cr) + assert.Equal(t, expectedSvc.Spec.Type, svc.Spec.Type) + assert.Equal(t, expectedSvc.Spec.Ports, svc.Spec.Ports) + assert.Equal(t, expectedSvc.Spec.Selector, svc.Spec.Selector) +} + +func TestReconcileAgentMetricsService_ServiceExists_AgentNotSet(t *testing.T) { + // Test case: Metrics service exists but agent spec is not set (nil) + // Expected behavior: Should delete the metrics service + + cr := makeTestArgoCD() // No agent configuration + + // Create existing Service + expectedSvc := buildService(generateAgentResourceName(cr.Name, testAgentCompName)+"-metrics", testAgentCompName, cr) + expectedSvc.Spec = buildAgentMetricsServiceSpec(testAgentCompName, cr) + existingSvc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-metrics"), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Spec: expectedSvc.Spec, + } + + resObjs := []client.Object{cr, existingSvc} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentMetricsService(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + + // Verify Service was deleted + svc := &corev1.Service{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-metrics"), + Namespace: cr.Namespace, + }, svc) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentMetricsService_ServiceDoesNotExist_AgentNotSet(t *testing.T) { + // Test case: Metrics service doesn't exist and agent spec is not set (nil) + // Expected behavior: Should do nothing + + cr := makeTestArgoCD() // No agent configuration + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentMetricsService(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + + // Verify Service was not created + svc := &corev1.Service{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-metrics"), + Namespace: cr.Namespace, + }, svc) + assert.True(t, errors.IsNotFound(err)) +} + +// Healthz Service Tests + +func TestReconcileAgentHealthzService_ServiceDoesNotExist_AgentDisabled(t *testing.T) { + // Test case: Healthz service doesn't exist and agent is disabled + // Expected behavior: Should do nothing (no creation, no error) + + cr := makeTestArgoCD(withAgentEnabled(false)) + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentHealthzService(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + + // Verify Service was not created + svc := &corev1.Service{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-healthz"), + Namespace: cr.Namespace, + }, svc) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentHealthzService_ServiceDoesNotExist_AgentEnabled(t *testing.T) { + // Test case: Healthz service doesn't exist and agent is enabled + // Expected behavior: Should create the Service with expected spec + + cr := makeTestArgoCD(withAgentEnabled(true)) + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentHealthzService(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + + // Verify Service was created + svc := &corev1.Service{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-healthz"), + Namespace: cr.Namespace, + }, svc) + assert.NoError(t, err) + + // Verify Service has expected metadata + expectedName := generateAgentResourceName(cr.Name, testAgentCompName+"-healthz") + assert.Equal(t, expectedName, svc.Name) + assert.Equal(t, cr.Namespace, svc.Namespace) + assert.Equal(t, buildLabelsForAgent(cr.Name, testAgentCompName), svc.Labels) + + // Verify Service has correct healthz port configuration + assert.Len(t, svc.Spec.Ports, 1) + healthzPort := svc.Spec.Ports[0] + assert.Equal(t, "healthz", healthzPort.Name) + assert.Equal(t, int32(8002), healthzPort.Port) + assert.Equal(t, intstr.FromInt(8002), healthzPort.TargetPort) + assert.Equal(t, corev1.ProtocolTCP, healthzPort.Protocol) + + // Verify Service type is ClusterIP + assert.Equal(t, corev1.ServiceTypeClusterIP, svc.Spec.Type) + + // Verify owner reference is set + assert.Len(t, svc.OwnerReferences, 1) + assert.Equal(t, cr.Name, svc.OwnerReferences[0].Name) + assert.Equal(t, "ArgoCD", svc.OwnerReferences[0].Kind) +} + +func TestReconcileAgentHealthzService_ServiceExists_AgentDisabled(t *testing.T) { + // Test case: Healthz service exists and agent is disabled + // Expected behavior: Should delete the Service + + cr := makeTestArgoCD(withAgentEnabled(false)) + + // Create existing Service + expectedSvc := buildService(generateAgentResourceName(cr.Name, testAgentCompName)+"-healthz", testAgentCompName, cr) + expectedSvc.Spec = buildAgentHealthzServiceSpec(testAgentCompName, cr) + existingSvc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-healthz"), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Spec: expectedSvc.Spec, + } + + resObjs := []client.Object{cr, existingSvc} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentHealthzService(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + + // Verify Service was deleted + svc := &corev1.Service{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-healthz"), + Namespace: cr.Namespace, + }, svc) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentHealthzService_ServiceExists_AgentEnabled_SameSpec(t *testing.T) { + // Test case: Healthz service exists, agent is enabled, and spec is the same + // Expected behavior: Should do nothing (no update) + + cr := makeTestArgoCD(withAgentEnabled(true)) + + expectedSvc := buildService(generateAgentResourceName(cr.Name, testAgentCompName)+"-healthz", testAgentCompName, cr) + expectedSvc.Spec = buildAgentHealthzServiceSpec(testAgentCompName, cr) + existingSvc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-healthz"), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Spec: expectedSvc.Spec, + } + + resObjs := []client.Object{cr, existingSvc} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentHealthzService(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + + // Verify Service still exists with same spec + svc := &corev1.Service{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-healthz"), + Namespace: cr.Namespace, + }, svc) + assert.NoError(t, err) + assert.Equal(t, expectedSvc.Spec.Type, svc.Spec.Type) + assert.Equal(t, expectedSvc.Spec.Ports, svc.Spec.Ports) + assert.Equal(t, expectedSvc.Spec.Selector, svc.Spec.Selector) +} + +func TestReconcileAgentHealthzService_ServiceExists_AgentEnabled_DifferentSpec(t *testing.T) { + // Test case: Healthz service exists, agent is enabled, but spec is different + // Expected behavior: Should update the healthz service with expected spec + + cr := makeTestArgoCD(withAgentEnabled(true)) + + // Create existing Service with different spec + differentSpec := corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, // Different from expected ClusterIP + Ports: []corev1.ServicePort{ + { + Name: "http", // Different port name + Port: 80, // Different port + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromInt(8080), // Different target port + }, + }, + Selector: map[string]string{ + "app": "different-app", // Different selector + }, + } + + existingSvc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-healthz"), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Spec: differentSpec, + } + + resObjs := []client.Object{cr, existingSvc} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentHealthzService(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + + // Verify Service was updated with expected spec + svc := &corev1.Service{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-healthz"), + Namespace: cr.Namespace, + }, svc) + assert.NoError(t, err) + + expectedSvc := buildService(generateAgentResourceName(cr.Name, testAgentCompName)+"-healthz", testAgentCompName, cr) + expectedSvc.Spec = buildAgentHealthzServiceSpec(testAgentCompName, cr) + assert.Equal(t, expectedSvc.Spec.Type, svc.Spec.Type) + assert.Equal(t, expectedSvc.Spec.Ports, svc.Spec.Ports) + assert.Equal(t, expectedSvc.Spec.Selector, svc.Spec.Selector) +} + +func TestReconcileAgentHealthzService_ServiceExists_AgentNotSet(t *testing.T) { + // Test case: Healthz service exists but agent spec is not set (nil) + // Expected behavior: Should delete the healthz service + + cr := makeTestArgoCD() // No agent configuration + + // Create existing Service + expectedSvc := buildService(generateAgentResourceName(cr.Name, testAgentCompName)+"-healthz", testAgentCompName, cr) + expectedSvc.Spec = buildAgentHealthzServiceSpec(testAgentCompName, cr) + existingSvc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-healthz"), + Namespace: cr.Namespace, + Labels: buildLabelsForAgent(cr.Name, testAgentCompName), + }, + Spec: expectedSvc.Spec, + } + + resObjs := []client.Object{cr, existingSvc} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentHealthzService(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + + // Verify Service was deleted + svc := &corev1.Service{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-healthz"), + Namespace: cr.Namespace, + }, svc) + assert.True(t, errors.IsNotFound(err)) +} + +func TestReconcileAgentHealthzService_ServiceDoesNotExist_AgentNotSet(t *testing.T) { + // Test case: Healthz service doesn't exist and agent spec is not set (nil) + // Expected behavior: Should do nothing + + cr := makeTestArgoCD() // No agent configuration + + resObjs := []client.Object{cr} + sch := makeTestReconcilerScheme() + cl := makeTestReconcilerClient(sch, resObjs) + + err := ReconcileAgentHealthzService(cl, testAgentCompName, cr, sch) + assert.NoError(t, err) + + // Verify Service was not created + svc := &corev1.Service{} + err = cl.Get(context.TODO(), types.NamespacedName{ + Name: generateAgentResourceName(cr.Name, testAgentCompName+"-healthz"), + Namespace: cr.Namespace, + }, svc) + assert.True(t, errors.IsNotFound(err)) +} diff --git a/controllers/argocdagent/docs/quickstart.md b/controllers/argocdagent/docs/quickstart.md index 91cde0890..5fb781619 100644 --- a/controllers/argocdagent/docs/quickstart.md +++ b/controllers/argocdagent/docs/quickstart.md @@ -113,8 +113,27 @@ argocd-agentctl pki issue agent agent-autonomous --agent-context vcluster-agent- Now you can connect agents to principal. -While starting up agents you need to provide host names of principal in [agent-params-cm](https://github.com/argoproj-labs/argocd-agent/blob/main/install/kubernetes/agent/agent-params-cm.yaml#L54) ConfigMap to point it to principal server. +Use the yaml content below to create an Argo CD instance in the workload cluster `argocd` namespace. +```yaml +kind: ArgoCD +metadata: + name: argocd +spec: + server: + enabled: false + argoCDAgent: + agent: + enabled: true + client: + principalServerAddress: "_REPLACE_ME_" + principalServerPort: "443" + logLevel: "trace" + mode: "managed" # autonomous + creds: "mtls:any" + tls: + insecure: true +``` ### Step 9: Verification diff --git a/controllers/argocdagent/example/argocd-agent.yaml b/controllers/argocdagent/example/argocd-agent.yaml new file mode 100644 index 000000000..7bce99ce6 --- /dev/null +++ b/controllers/argocdagent/example/argocd-agent.yaml @@ -0,0 +1,52 @@ +apiVersion: argoproj.io/v1beta1 +kind: ArgoCD +metadata: + name: argocd-agent +spec: + # Disable the server on the workload cluster + server: + enabled: false + + # ArgoCD Agent configuration + argoCDAgent: + agent: + # Enable the Agent component + enabled: true + + # Client configuration for the Agent component + client: + # Address of the principal server to connect to + # Update this to point to your principal server + principalServerAddress: "argocd-agent-principal.example.com" + # Port of the principal server + principalServerPort: "443" + # Log level for the Agent component + logLevel: "info" + # Log format (text or json) + logFormat: "text" + # Container image for the ArgoCD Agent + image: "quay.io/argoprojlabs/argocd-agent:v0.3.2" + # Operational mode (autonomous or managed) + mode: "managed" + # Credential method for authentication (mTLS) + creds: "mtls:any" + # Enable WebSocket for real-time event streaming + enableWebSocket: false + # Enable compression for gRPC communication + enableCompression: false + # Keep-alive interval for pings to the principal + keepAliveInterval: "30s" + + # TLS configuration for the agent + tls: + # Secret containing the agent client TLS certificate + secretName: "argocd-agent-client-tls" + # Secret containing the root CA certificate + rootCASecretName: "argocd-agent-ca" + # Skip TLS certificate validation (insecure, for development only) + insecure: false + + # Redis configuration for caching and state management + redis: + # Redis server address + serverAddress: "argocd-redis:6379" diff --git a/controllers/argocdagent/example/argocd.yaml b/controllers/argocdagent/example/argocd-principal.yaml similarity index 98% rename from controllers/argocdagent/example/argocd.yaml rename to controllers/argocdagent/example/argocd-principal.yaml index ae73c598c..0fb1f9ed3 100644 --- a/controllers/argocdagent/example/argocd.yaml +++ b/controllers/argocdagent/example/argocd-principal.yaml @@ -1,7 +1,7 @@ apiVersion: argoproj.io/v1beta1 kind: ArgoCD metadata: - name: argocd + name: argocd-principal spec: # Disable the main controller since we're using the agent controller: @@ -93,4 +93,4 @@ spec: # Source namespaces for ArgoCD applications sourceNamespaces: - "agent-managed" - - "agent-autonomous" \ No newline at end of file + - "agent-autonomous" diff --git a/deploy/olm-catalog/argocd-operator/0.17.0/argocd-operator.v0.17.0.clusterserviceversion.yaml b/deploy/olm-catalog/argocd-operator/0.17.0/argocd-operator.v0.17.0.clusterserviceversion.yaml index 2799f67ce..dace928a6 100644 --- a/deploy/olm-catalog/argocd-operator/0.17.0/argocd-operator.v0.17.0.clusterserviceversion.yaml +++ b/deploy/olm-catalog/argocd-operator/0.17.0/argocd-operator.v0.17.0.clusterserviceversion.yaml @@ -257,7 +257,7 @@ metadata: capabilities: Deep Insights categories: Integration & Delivery certified: "false" - createdAt: "2025-11-03T14:36:18Z" + createdAt: "2025-11-04T15:44:28Z" description: Argo CD is a declarative, GitOps continuous delivery tool for Kubernetes. operators.operatorframework.io/builder: operator-sdk-v1.35.0 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/deploy/olm-catalog/argocd-operator/0.17.0/argoproj.io_argocds.yaml b/deploy/olm-catalog/argocd-operator/0.17.0/argoproj.io_argocds.yaml index 8ec45d7c0..5c5744fb5 100644 --- a/deploy/olm-catalog/argocd-operator/0.17.0/argoproj.io_argocds.yaml +++ b/deploy/olm-catalog/argocd-operator/0.17.0/argoproj.io_argocds.yaml @@ -450,6 +450,208 @@ spec: description: ArgoCDAgent defines configurations for the ArgoCD Agent component. properties: + agent: + description: Agent defines configurations for the Agent component + of Argo CD Agent. + properties: + client: + description: Client defines the client options for the Agent + component. + properties: + creds: + description: Creds is the credential identifier for the + agent authentication + type: string + enableCompression: + description: EnableCompression is the flag to enable compression + while sending data between Principal and Agent using + gRPC + type: boolean + enableWebSocket: + description: EnableWebSocket is the flag to enable WebSocket + for event streaming + type: boolean + env: + description: Env lets you specify environment for agent + pods + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + image: + description: Image is the name of Argo CD Agent image + type: string + keepAliveInterval: + description: KeepAliveInterval is the interval for keep-alive + pings to the principal + type: string + logFormat: + description: LogFormat refers to the log format used by + the Agent component. + type: string + logLevel: + description: LogLevel refers to the log level used by + the Agent component. + type: string + mode: + description: Mode is the operational mode for the agent + (managed or autonomous) + type: string + principalServerAddress: + description: PrincipalServerAddress is the remote address + of the principal server to connect to. + type: string + principalServerPort: + description: PrincipalServerPort is the remote port of + the principal server to connect to. + type: string + type: object + enabled: + description: Enabled is the flag to enable the Agent component + during Argo CD installation. (optional, default `false`) + type: boolean + redis: + description: Redis defines the Redis options for the Agent + component. + properties: + serverAddress: + description: ServerAddress is the address of the Redis + server to be used by the PrincAgentipal component. + type: string + type: object + tls: + description: TLS defines the TLS options for the Agent component. + properties: + insecure: + description: Insecure is the flag to skip TLS certificate + validation when connecting to the principal (insecure, + for development only) + type: boolean + rootCASecretName: + description: RootCASecretName is the name of the secret + containing the root CA certificate + type: string + secretName: + description: SecretName is the name of the secret containing + the agent client TLS certificate + type: string + type: object + type: object principal: description: Principal defines configurations for the Principal component of Argo CD Agent. @@ -10440,6 +10642,208 @@ spec: description: ArgoCDAgent defines configurations for the ArgoCD Agent component. properties: + agent: + description: Agent defines configurations for the Agent component + of Argo CD Agent. + properties: + client: + description: Client defines the client options for the Agent + component. + properties: + creds: + description: Creds is the credential identifier for the + agent authentication + type: string + enableCompression: + description: EnableCompression is the flag to enable compression + while sending data between Principal and Agent using + gRPC + type: boolean + enableWebSocket: + description: EnableWebSocket is the flag to enable WebSocket + for event streaming + type: boolean + env: + description: Env lets you specify environment for agent + pods + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + image: + description: Image is the name of Argo CD Agent image + type: string + keepAliveInterval: + description: KeepAliveInterval is the interval for keep-alive + pings to the principal + type: string + logFormat: + description: LogFormat refers to the log format used by + the Agent component. + type: string + logLevel: + description: LogLevel refers to the log level used by + the Agent component. + type: string + mode: + description: Mode is the operational mode for the agent + (managed or autonomous) + type: string + principalServerAddress: + description: PrincipalServerAddress is the remote address + of the principal server to connect to. + type: string + principalServerPort: + description: PrincipalServerPort is the remote port of + the principal server to connect to. + type: string + type: object + enabled: + description: Enabled is the flag to enable the Agent component + during Argo CD installation. (optional, default `false`) + type: boolean + redis: + description: Redis defines the Redis options for the Agent + component. + properties: + serverAddress: + description: ServerAddress is the address of the Redis + server to be used by the PrincAgentipal component. + type: string + type: object + tls: + description: TLS defines the TLS options for the Agent component. + properties: + insecure: + description: Insecure is the flag to skip TLS certificate + validation when connecting to the principal (insecure, + for development only) + type: boolean + rootCASecretName: + description: RootCASecretName is the name of the secret + containing the root CA certificate + type: string + secretName: + description: SecretName is the name of the secret containing + the agent client TLS certificate + type: string + type: object + type: object principal: description: Principal defines configurations for the Principal component of Argo CD Agent. diff --git a/tests/ginkgo/sequential/1-052_validate_argocd_agent_agent_test.go b/tests/ginkgo/sequential/1-052_validate_argocd_agent_agent_test.go new file mode 100644 index 000000000..7a01bced4 --- /dev/null +++ b/tests/ginkgo/sequential/1-052_validate_argocd_agent_agent_test.go @@ -0,0 +1,448 @@ +/* +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 sequential + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + argov1beta1api "github.com/argoproj-labs/argocd-operator/api/v1beta1" + "github.com/argoproj-labs/argocd-operator/common" + "github.com/argoproj-labs/argocd-operator/controllers/argocdagent/agent" + "github.com/argoproj-labs/argocd-operator/tests/ginkgo/fixture" + argocdFixture "github.com/argoproj-labs/argocd-operator/tests/ginkgo/fixture/argocd" + deploymentFixture "github.com/argoproj-labs/argocd-operator/tests/ginkgo/fixture/deployment" + k8sFixture "github.com/argoproj-labs/argocd-operator/tests/ginkgo/fixture/k8s" + fixtureUtils "github.com/argoproj-labs/argocd-operator/tests/ginkgo/fixture/utils" +) + +var _ = Describe("GitOps Operator Sequential E2E Tests", func() { + + const ( + argoCDName = "example" + argoCDAgentAgentName = "example-agent-agent" // argoCDName + "-agent-agent" + ) + + Context("1-052_validate_argocd_agent_agent", func() { + + var ( + k8sClient client.Client + ctx context.Context + argoCD *argov1beta1api.ArgoCD + ns *corev1.Namespace + cleanupFunc func() + serviceAccount *corev1.ServiceAccount + role *rbacv1.Role + roleBinding *rbacv1.RoleBinding + clusterRole *rbacv1.ClusterRole + clusterRoleBinding *rbacv1.ClusterRoleBinding + serviceNames []string + deploymentNames []string + agentDeployment *appsv1.Deployment + expectedEnvVariables map[string]string + secretNames []string + ) + + BeforeEach(func() { + fixture.EnsureSequentialCleanSlate() + k8sClient, _ = fixtureUtils.GetE2ETestKubeClient() + ctx = context.Background() + ns, cleanupFunc = fixture.CreateNamespaceWithCleanupFunc("argocd-agent-agent-1-052") + + // Define ArgoCD CR with agent enabled + argoCD = &argov1beta1api.ArgoCD{ + ObjectMeta: metav1.ObjectMeta{ + Name: argoCDName, + Namespace: ns.Name, + }, + Spec: argov1beta1api.ArgoCDSpec{ + Controller: argov1beta1api.ArgoCDApplicationControllerSpec{ + Enabled: ptr.To(false), + }, + Server: argov1beta1api.ArgoCDServerSpec{ + Enabled: ptr.To(false), + }, + ArgoCDAgent: &argov1beta1api.ArgoCDAgentSpec{ + Agent: &argov1beta1api.AgentSpec{ + Enabled: ptr.To(true), + Client: &argov1beta1api.AgentClientSpec{ + PrincipalServerAddress: "argocd-agent-principal.example.com", + PrincipalServerPort: "443", + LogLevel: "info", + LogFormat: "text", + Mode: "managed", + Creds: "mtls:any", + EnableWebSocket: ptr.To(false), + EnableCompression: ptr.To(false), + KeepAliveInterval: "30s", + }, + TLS: &argov1beta1api.AgentTLSSpec{ + SecretName: "argocd-agent-client-tls", + RootCASecretName: "argocd-agent-ca", + Insecure: ptr.To(false), + }, + Redis: &argov1beta1api.AgentRedisSpec{ + ServerAddress: fmt.Sprintf("%s-%s:%d", argoCDName, "redis", common.ArgoCDDefaultRedisPort), + }, + }, + }, + }, + } + + // Define required resources for agent pod + serviceAccount = &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: argoCDAgentAgentName, + Namespace: ns.Name, + }, + } + + role = &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: argoCDAgentAgentName, + Namespace: ns.Name, + }, + } + + roleBinding = &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: argoCDAgentAgentName, + Namespace: ns.Name, + }, + } + + clusterRole = &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%s-agent-agent", argoCDName, ns.Name), + }, + } + + clusterRoleBinding = &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%s-agent-agent", argoCDName, ns.Name), + }, + } + + // List required secrets for agent pod + secretNames = []string{ + "argocd-agent-client-tls", + "argocd-agent-ca", + "example-redis-initial-password", + } + + serviceNames = []string{ + fmt.Sprintf("%s-agent-agent-metrics", argoCDName), + fmt.Sprintf("%s-agent-agent-healthz", argoCDName), + fmt.Sprintf("%s-redis", argoCDName), + } + deploymentNames = []string{fmt.Sprintf("%s-redis", argoCDName)} + + agentDeployment = &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: argoCDAgentAgentName, + Namespace: ns.Name, + }, + } + + // List environment variables with expected values for the agent deployment + expectedEnvVariables = map[string]string{ + agent.EnvArgoCDAgentLogLevel: "info", + agent.EnvArgoCDAgentNamespace: ns.Name, + agent.EnvArgoCDAgentServerAddress: "argocd-agent-principal.example.com", + agent.EnvArgoCDAgentServerPort: "443", + agent.EnvArgoCDAgentLogFormat: "text", + agent.EnvArgoCDAgentTLSSecretName: "argocd-agent-client-tls", + agent.EnvArgoCDAgentTLSInsecure: "false", + agent.EnvArgoCDAgentTLSRootCASecretName: "argocd-agent-ca", + agent.EnvArgoCDAgentMode: "managed", + agent.EnvArgoCDAgentCreds: "mtls:any", + agent.EnvArgoCDAgentEnableWebSocket: "false", + agent.EnvArgoCDAgentEnableCompression: "false", + agent.EnvArgoCDAgentKeepAliveInterval: "30s", + agent.EnvArgoCDAgentRedisAddress: fmt.Sprintf("%s-%s:%d", argoCDName, "redis", common.ArgoCDDefaultRedisPort), + agent.EnvArgoCDAgentEnableResourceProxy: "true", + } + }) + + AfterEach(func() { + By("Cleanup namespace") + if cleanupFunc != nil { + cleanupFunc() + } + }) + + // verifyExpectedResourcesExist will verify that the resources that are created for agent and ArgoCD are created. + verifyExpectedResourcesExist := func(ns *corev1.Namespace) { + + By("verifying expected resources exist") + Eventually(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretNames[2], Namespace: ns.Name, + }}, "60s", "2s").Should(k8sFixture.ExistByName()) + Eventually(serviceAccount).Should(k8sFixture.ExistByName()) + Eventually(role).Should(k8sFixture.ExistByName()) + Eventually(roleBinding).Should(k8sFixture.ExistByName()) + Eventually(clusterRole).Should(k8sFixture.ExistByName()) + defer func() { + _ = k8sClient.Delete(ctx, clusterRole) + }() + + Eventually(clusterRoleBinding).Should(k8sFixture.ExistByName()) + defer func() { + _ = k8sClient.Delete(ctx, clusterRoleBinding) + }() + + for _, serviceName := range serviceNames { + + By("verifying Service '" + serviceName + "' exists and is a LoadBalancer or ClusterIP depending on which service") + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: ns.Name, + }, + } + Eventually(service).Should(k8sFixture.ExistByName()) + Expect(string(service.Spec.Type)).To(Equal("ClusterIP")) + } + + for _, deploymentName := range deploymentNames { + + By("verifying Deployment '" + deploymentName + "' exists and is ready") + + depl := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: deploymentName, + Namespace: ns.Name, + }, + } + Eventually(depl).Should(k8sFixture.ExistByName()) + } + + By("verifying primary agent Deployment has expected values") + + Eventually(agentDeployment).Should(k8sFixture.ExistByName()) + Eventually(agentDeployment).Should(k8sFixture.HaveLabelWithValue("app.kubernetes.io/component", "agent")) + Eventually(agentDeployment).Should(k8sFixture.HaveLabelWithValue("app.kubernetes.io/managed-by", argoCDName)) + Eventually(agentDeployment).Should(k8sFixture.HaveLabelWithValue("app.kubernetes.io/name", argoCDAgentAgentName)) + Eventually(agentDeployment).Should(k8sFixture.HaveLabelWithValue("app.kubernetes.io/part-of", "argocd-agent")) + } + + // verifyResourcesDeleted will verify that the various resources that are created for agent are deleted. + verifyResourcesDeleted := func() { + + By("verifying resources are deleted for agent pod") + + Eventually(serviceAccount).Should(k8sFixture.NotExistByName()) + Eventually(role).Should(k8sFixture.NotExistByName()) + Eventually(roleBinding).Should(k8sFixture.NotExistByName()) + Eventually(clusterRole).Should(k8sFixture.NotExistByName()) + Eventually(clusterRoleBinding).Should(k8sFixture.NotExistByName()) + Eventually(agentDeployment).Should(k8sFixture.NotExistByName()) + + for _, serviceName := range []string{fmt.Sprintf("%s-agent-agent-metrics", argoCDName), fmt.Sprintf("%s-agent-agent-healthz", argoCDName)} { + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: ns.Name, + }, + } + Eventually(service).Should(k8sFixture.NotExistByName()) + } + } + + It("should create argocd agent agent resources, but pod should not be expected to run successfully without principal", func() { + // Change log level to trace and custom image name + argoCD.Spec.ArgoCDAgent.Agent.Client.LogLevel = "trace" + argoCD.Spec.ArgoCDAgent.Agent.Client.Image = "quay.io/user/argocd-agent:v1" + + By("Create ArgoCD instance") + + Expect(k8sClient.Create(ctx, argoCD)).To(Succeed()) + + By("Verify expected resources are created for agent pod") + + verifyExpectedResourcesExist(ns) + + By("Verify agent has the custom image we specified in ArgoCD CR") + + container := deploymentFixture.GetTemplateSpecContainerByName(argoCDAgentAgentName, *agentDeployment) + Expect(container).ToNot(BeNil()) + Expect(container.Image).To(Equal("quay.io/user/argocd-agent:v1")) + + By("Verify environment variables are set correctly") + + // update expected value in default environment variables according to ArgoCD CR in the test + expectedEnvVariables[agent.EnvArgoCDAgentLogLevel] = "trace" + + for key, value := range expectedEnvVariables { + Expect(container.Env).To(ContainElement(corev1.EnvVar{Name: key, Value: value}), "Environment variable %s should be set to %s", key, value) + } + + By("Disable agent") + + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: argoCDName, Namespace: ns.Name}, argoCD)).To(Succeed()) + + argocdFixture.Update(argoCD, func(ac *argov1beta1api.ArgoCD) { + ac.Spec.ArgoCDAgent.Agent.Enabled = ptr.To(false) + }) + + By("Verify agent resources are deleted") + + verifyResourcesDeleted() + }) + + It("should create argocd agent agent resources with default image, but pod will not start without principal", func() { + + // Add a custom environment variable to the agent client + argoCD.Spec.ArgoCDAgent.Agent.Client.Env = []corev1.EnvVar{{Name: "TEST_ENV", Value: "test_value"}} + + By("Create ArgoCD instance") + + Expect(k8sClient.Create(ctx, argoCD)).To(Succeed()) + + By("Verify expected resources are created for agent pod") + + verifyExpectedResourcesExist(ns) + + By("Verify agent uses the default agent image") + + container := deploymentFixture.GetTemplateSpecContainerByName(argoCDAgentAgentName, *agentDeployment) + Expect(container).ToNot(BeNil()) + Expect(container.Image).To(Equal("quay.io/argoprojlabs/argocd-agent:v0.3.2")) + + By("Verify environment variables are set correctly") + + for key, value := range expectedEnvVariables { + Expect(container.Env).To(ContainElement(corev1.EnvVar{Name: key, Value: value}), "Environment variable %s should be set to %s", key, value) + } + + Expect(container.Env).To(ContainElement(And( + HaveField("Name", agent.EnvRedisPassword), + HaveField("ValueFrom.SecretKeyRef", Not(BeNil())), + )), "REDIS_PASSWORD should be set with valueFrom.secretKeyRef") + + By("Verify custom environment variable is present") + + Expect(container.Env).To(ContainElement(corev1.EnvVar{Name: "TEST_ENV", Value: "test_value"}), "Custom environment variable TEST_ENV should be set") + + By("Disable agent") + + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: argoCDName, Namespace: ns.Name}, argoCD)).To(Succeed()) + + argocdFixture.Update(argoCD, func(ac *argov1beta1api.ArgoCD) { + ac.Spec.ArgoCDAgent.Agent.Enabled = nil + }) + + By("Verify agent resources are deleted") + + verifyResourcesDeleted() + }) + + It("Should reflect configuration changes from ArgoCD CR to the agent deployment", func() { + + By("Create ArgoCD instance") + + argoCD.Spec.ArgoCDAgent.Agent.Client.Image = "quay.io/jparsai/argocd-agent:test" + Expect(k8sClient.Create(ctx, argoCD)).To(Succeed()) + + By("Verify expected resources are created for agent pod") + + verifyExpectedResourcesExist(ns) + + By("Verify agent has the custom image we specified in ArgoCD CR") + + container := deploymentFixture.GetTemplateSpecContainerByName(argoCDAgentAgentName, *agentDeployment) + Expect(container).ToNot(BeNil()) + Expect(container.Image).To(Equal("quay.io/jparsai/argocd-agent:test")) + + By("Verify environment variables are set correctly") + + // update expected value in default environment variables according to ArgoCD CR in the test + for key, value := range expectedEnvVariables { + Expect(container.Env).To(ContainElement(corev1.EnvVar{Name: key, Value: value}), "Environment variable %s should be set to %s", key, value) + } + + By("Update ArgoCD CR with new configuration") + + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: argoCDName, Namespace: ns.Name}, argoCD)).To(Succeed()) + + argocdFixture.Update(argoCD, func(ac *argov1beta1api.ArgoCD) { + + ac.Spec.ArgoCDAgent.Agent.Client.LogLevel = "trace" + ac.Spec.ArgoCDAgent.Agent.Client.LogFormat = "json" + ac.Spec.ArgoCDAgent.Agent.Client.KeepAliveInterval = "60s" + ac.Spec.ArgoCDAgent.Agent.Client.EnableWebSocket = ptr.To(true) + ac.Spec.ArgoCDAgent.Agent.Client.EnableCompression = ptr.To(true) + ac.Spec.ArgoCDAgent.Agent.Client.Image = "quay.io/jparsai/argocd-agent:test1" + ac.Spec.ArgoCDAgent.Agent.Client.Mode = "autonomous" + ac.Spec.ArgoCDAgent.Agent.Client.PrincipalServerAddress = "argocd-agent-principal-updated.example.com" + ac.Spec.ArgoCDAgent.Agent.Client.PrincipalServerPort = "8443" + + ac.Spec.ArgoCDAgent.Agent.TLS.Insecure = ptr.To(true) + ac.Spec.ArgoCDAgent.Agent.TLS.SecretName = "argocd-agent-client-tls-v2" + ac.Spec.ArgoCDAgent.Agent.TLS.RootCASecretName = "argocd-agent-ca-v2" + + }) + + By("Verify agent has the updated image we specified in ArgoCD CR") + + Eventually(agentDeployment).Should(k8sFixture.ExistByName()) + Eventually( + func() bool { + // Fetch the latest deployment from the cluster + err := k8sClient.Get(ctx, client.ObjectKey{Name: argoCDAgentAgentName, Namespace: ns.Name}, agentDeployment) + if err != nil { + GinkgoWriter.Println("Error getting deployment for image check: ", err) + return false + } + container = deploymentFixture.GetTemplateSpecContainerByName(argoCDAgentAgentName, *agentDeployment) + if container == nil { + return false + } + return container.Image == "quay.io/jparsai/argocd-agent:test1" + }, "120s", "5s").Should(BeTrue(), "Agent deployment should have the updated image") + + By("Verify environment variables are updated correctly") + + expectedEnvVariables[agent.EnvArgoCDAgentLogLevel] = "trace" + expectedEnvVariables[agent.EnvArgoCDAgentLogFormat] = "json" + expectedEnvVariables[agent.EnvArgoCDAgentKeepAliveInterval] = "60s" + expectedEnvVariables[agent.EnvArgoCDAgentEnableWebSocket] = "true" + expectedEnvVariables[agent.EnvArgoCDAgentEnableCompression] = "true" + expectedEnvVariables[agent.EnvArgoCDAgentMode] = "autonomous" + expectedEnvVariables[agent.EnvArgoCDAgentServerAddress] = "argocd-agent-principal-updated.example.com" + expectedEnvVariables[agent.EnvArgoCDAgentServerPort] = "8443" + expectedEnvVariables[agent.EnvArgoCDAgentTLSInsecure] = "true" + expectedEnvVariables[agent.EnvArgoCDAgentTLSSecretName] = "argocd-agent-client-tls-v2" + expectedEnvVariables[agent.EnvArgoCDAgentTLSRootCASecretName] = "argocd-agent-ca-v2" + + for key, value := range expectedEnvVariables { + Expect(container.Env).To(ContainElement(corev1.EnvVar{Name: key, Value: value}), "Environment variable %s should be set to %s", key, value) + } + }) + }) +})