diff --git a/config/crd/external/monitoring.coreos.com_servicemonitors.yaml b/config/crd/external/monitoring.coreos.com_servicemonitors.yaml new file mode 100644 index 0000000..5713531 --- /dev/null +++ b/config/crd/external/monitoring.coreos.com_servicemonitors.yaml @@ -0,0 +1,990 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.15.0 + operator.prometheus.io/version: 0.75.0 + name: servicemonitors.monitoring.coreos.com +spec: + group: monitoring.coreos.com + names: + categories: + - prometheus-operator + kind: ServiceMonitor + listKind: ServiceMonitorList + plural: servicemonitors + shortNames: + - smon + singular: servicemonitor + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + description: ServiceMonitor defines monitoring for a set of services. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + Specification of desired Service selection for target discovery by + Prometheus. + properties: + attachMetadata: + description: |- + `attachMetadata` defines additional metadata which is added to the + discovered targets. + + + It requires Prometheus >= v2.37.0. + properties: + node: + description: |- + When set to true, Prometheus must have the `get` permission on the + `Nodes` objects. + type: boolean + type: object + bodySizeLimit: + description: |- + When defined, bodySizeLimit specifies a job level limit on the size + of uncompressed response body that will be accepted by Prometheus. + + + It requires Prometheus >= v2.28.0. + pattern: (^0|([0-9]*[.])?[0-9]+((K|M|G|T|E|P)i?)?B)$ + type: string + endpoints: + description: List of endpoints part of this ServiceMonitor. + items: + description: |- + Endpoint defines an endpoint serving Prometheus metrics to be scraped by + Prometheus. + properties: + authorization: + description: |- + `authorization` configures the Authorization header credentials to use when + scraping the target. + + + Cannot be set at the same time as `basicAuth`, or `oauth2`. + properties: + credentials: + description: Selects a key of a Secret in the namespace + that contains the credentials for authentication. + 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. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + 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: + description: |- + Defines the authentication type. The value is case-insensitive. + + + "Basic" is not a supported value. + + + Default: "Bearer" + type: string + type: object + basicAuth: + description: |- + `basicAuth` configures the Basic Authentication credentials to use when + scraping the target. + + + Cannot be set at the same time as `authorization`, or `oauth2`. + properties: + password: + description: |- + `password` specifies a key of a Secret containing the password for + authentication. + 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. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + 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 + username: + description: |- + `username` specifies a key of a Secret containing the username for + authentication. + 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. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + 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 + bearerTokenFile: + description: |- + File to read bearer token for scraping the target. + + + Deprecated: use `authorization` instead. + type: string + bearerTokenSecret: + description: |- + `bearerTokenSecret` specifies a key of a Secret containing the bearer + token for scraping targets. The secret needs to be in the same namespace + as the ServiceMonitor object and readable by the Prometheus Operator. + + + Deprecated: use `authorization` instead. + 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. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + 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 + enableHttp2: + description: '`enableHttp2` can be used to disable HTTP2 when + scraping the target.' + type: boolean + filterRunning: + description: |- + When true, the pods which are not running (e.g. either in Failed or + Succeeded state) are dropped during the target discovery. + + + If unset, the filtering is enabled. + + + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-phase + type: boolean + followRedirects: + description: |- + `followRedirects` defines whether the scrape requests should follow HTTP + 3xx redirects. + type: boolean + honorLabels: + description: |- + When true, `honorLabels` preserves the metric's labels when they collide + with the target's labels. + type: boolean + honorTimestamps: + description: |- + `honorTimestamps` controls whether Prometheus preserves the timestamps + when exposed by the target. + type: boolean + interval: + description: |- + Interval at which Prometheus scrapes the metrics from the target. + + + If empty, Prometheus uses the global scrape interval. + pattern: ^(0|(([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?)$ + type: string + metricRelabelings: + description: |- + `metricRelabelings` configures the relabeling rules to apply to the + samples before ingestion. + items: + description: |- + RelabelConfig allows dynamic rewriting of the label set for targets, alerts, + scraped samples and remote write samples. + + + More info: https://prometheus.io/docs/prometheus/latest/configuration/configuration/#relabel_config + properties: + action: + default: replace + description: |- + Action to perform based on the regex matching. + + + `Uppercase` and `Lowercase` actions require Prometheus >= v2.36.0. + `DropEqual` and `KeepEqual` actions require Prometheus >= v2.41.0. + + + Default: "Replace" + enum: + - replace + - Replace + - keep + - Keep + - drop + - Drop + - hashmod + - HashMod + - labelmap + - LabelMap + - labeldrop + - LabelDrop + - labelkeep + - LabelKeep + - lowercase + - Lowercase + - uppercase + - Uppercase + - keepequal + - KeepEqual + - dropequal + - DropEqual + type: string + modulus: + description: |- + Modulus to take of the hash of the source label values. + + + Only applicable when the action is `HashMod`. + format: int64 + type: integer + regex: + description: Regular expression against which the extracted + value is matched. + type: string + replacement: + description: |- + Replacement value against which a Replace action is performed if the + regular expression matches. + + + Regex capture groups are available. + type: string + separator: + description: Separator is the string between concatenated + SourceLabels. + type: string + sourceLabels: + description: |- + The source labels select values from existing labels. Their content is + concatenated using the configured Separator and matched against the + configured regular expression. + items: + description: |- + LabelName is a valid Prometheus label name which may only contain ASCII + letters, numbers, as well as underscores. + pattern: ^[a-zA-Z_][a-zA-Z0-9_]*$ + type: string + type: array + targetLabel: + description: |- + Label to which the resulting string is written in a replacement. + + + It is mandatory for `Replace`, `HashMod`, `Lowercase`, `Uppercase`, + `KeepEqual` and `DropEqual` actions. + + + Regex capture groups are available. + type: string + type: object + type: array + oauth2: + description: |- + `oauth2` configures the OAuth2 settings to use when scraping the target. + + + It requires Prometheus >= 2.27.0. + + + Cannot be set at the same time as `authorization`, or `basicAuth`. + properties: + clientId: + description: |- + `clientId` specifies a key of a Secret or ConfigMap containing the + OAuth2 client's ID. + properties: + configMap: + description: ConfigMap containing data to use for the + targets. + 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. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + 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 + secret: + description: Secret containing data to use for the targets. + 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. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + 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 + clientSecret: + description: |- + `clientSecret` specifies a key of a Secret containing the OAuth2 + client's secret. + 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. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + 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 + endpointParams: + additionalProperties: + type: string + description: |- + `endpointParams` configures the HTTP parameters to append to the token + URL. + type: object + scopes: + description: '`scopes` defines the OAuth2 scopes used for + the token request.' + items: + type: string + type: array + tokenUrl: + description: '`tokenURL` configures the URL to fetch the + token from.' + minLength: 1 + type: string + required: + - clientId + - clientSecret + - tokenUrl + type: object + params: + additionalProperties: + items: + type: string + type: array + description: params define optional HTTP URL parameters. + type: object + path: + description: |- + HTTP path from which to scrape for metrics. + + + If empty, Prometheus uses the default value (e.g. `/metrics`). + type: string + port: + description: |- + Name of the Service port which this endpoint refers to. + + + It takes precedence over `targetPort`. + type: string + proxyUrl: + description: |- + `proxyURL` configures the HTTP Proxy URL (e.g. + "http://proxyserver:2195") to go through when scraping the target. + type: string + relabelings: + description: |- + `relabelings` configures the relabeling rules to apply the target's + metadata labels. + + + The Operator automatically adds relabelings for a few standard Kubernetes fields. + + + The original scrape job's name is available via the `__tmp_prometheus_job_name` label. + + + More info: https://prometheus.io/docs/prometheus/latest/configuration/configuration/#relabel_config + items: + description: |- + RelabelConfig allows dynamic rewriting of the label set for targets, alerts, + scraped samples and remote write samples. + + + More info: https://prometheus.io/docs/prometheus/latest/configuration/configuration/#relabel_config + properties: + action: + default: replace + description: |- + Action to perform based on the regex matching. + + + `Uppercase` and `Lowercase` actions require Prometheus >= v2.36.0. + `DropEqual` and `KeepEqual` actions require Prometheus >= v2.41.0. + + + Default: "Replace" + enum: + - replace + - Replace + - keep + - Keep + - drop + - Drop + - hashmod + - HashMod + - labelmap + - LabelMap + - labeldrop + - LabelDrop + - labelkeep + - LabelKeep + - lowercase + - Lowercase + - uppercase + - Uppercase + - keepequal + - KeepEqual + - dropequal + - DropEqual + type: string + modulus: + description: |- + Modulus to take of the hash of the source label values. + + + Only applicable when the action is `HashMod`. + format: int64 + type: integer + regex: + description: Regular expression against which the extracted + value is matched. + type: string + replacement: + description: |- + Replacement value against which a Replace action is performed if the + regular expression matches. + + + Regex capture groups are available. + type: string + separator: + description: Separator is the string between concatenated + SourceLabels. + type: string + sourceLabels: + description: |- + The source labels select values from existing labels. Their content is + concatenated using the configured Separator and matched against the + configured regular expression. + items: + description: |- + LabelName is a valid Prometheus label name which may only contain ASCII + letters, numbers, as well as underscores. + pattern: ^[a-zA-Z_][a-zA-Z0-9_]*$ + type: string + type: array + targetLabel: + description: |- + Label to which the resulting string is written in a replacement. + + + It is mandatory for `Replace`, `HashMod`, `Lowercase`, `Uppercase`, + `KeepEqual` and `DropEqual` actions. + + + Regex capture groups are available. + type: string + type: object + type: array + scheme: + description: |- + HTTP scheme to use for scraping. + + + `http` and `https` are the expected values unless you rewrite the + `__scheme__` label via relabeling. + + + If empty, Prometheus uses the default value `http`. + enum: + - http + - https + type: string + scrapeTimeout: + description: |- + Timeout after which Prometheus considers the scrape to be failed. + + + If empty, Prometheus uses the global scrape timeout unless it is less + than the target's scrape interval value in which the latter is used. + pattern: ^(0|(([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?)$ + type: string + targetPort: + anyOf: + - type: integer + - type: string + description: |- + Name or number of the target port of the `Pod` object behind the + Service. The port must be specified with the container's port property. + x-kubernetes-int-or-string: true + tlsConfig: + description: TLS configuration to use when scraping the target. + properties: + ca: + description: Certificate authority used when verifying server + certificates. + properties: + configMap: + description: ConfigMap containing data to use for the + targets. + 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. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + 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 + secret: + description: Secret containing data to use for the targets. + 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. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + 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 + caFile: + description: Path to the CA cert in the Prometheus container + to use for the targets. + type: string + cert: + description: Client certificate to present when doing client-authentication. + properties: + configMap: + description: ConfigMap containing data to use for the + targets. + 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. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + 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 + secret: + description: Secret containing data to use for the targets. + 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. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + 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 + certFile: + description: Path to the client cert file in the Prometheus + container for the targets. + type: string + insecureSkipVerify: + description: Disable target certificate validation. + type: boolean + keyFile: + description: Path to the client key file in the Prometheus + container for the targets. + type: string + keySecret: + description: Secret containing the client key file for the + targets. + 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. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + 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 + serverName: + description: Used to verify the hostname for the targets. + type: string + type: object + trackTimestampsStaleness: + description: |- + `trackTimestampsStaleness` defines whether Prometheus tracks staleness of + the metrics that have an explicit timestamp present in scraped data. + Has no effect if `honorTimestamps` is false. + + + It requires Prometheus >= v2.48.0. + type: boolean + type: object + type: array + jobLabel: + description: |- + `jobLabel` selects the label from the associated Kubernetes `Service` + object which will be used as the `job` label for all metrics. + + + For example if `jobLabel` is set to `foo` and the Kubernetes `Service` + object is labeled with `foo: bar`, then Prometheus adds the `job="bar"` + label to all ingested metrics. + + + If the value of this field is empty or if the label doesn't exist for + the given Service, the `job` label of the metrics defaults to the name + of the associated Kubernetes `Service`. + type: string + keepDroppedTargets: + description: |- + Per-scrape limit on the number of targets dropped by relabeling + that will be kept in memory. 0 means no limit. + + + It requires Prometheus >= v2.47.0. + format: int64 + type: integer + labelLimit: + description: |- + Per-scrape limit on number of labels that will be accepted for a sample. + + + It requires Prometheus >= v2.27.0. + format: int64 + type: integer + labelNameLengthLimit: + description: |- + Per-scrape limit on length of labels name that will be accepted for a sample. + + + It requires Prometheus >= v2.27.0. + format: int64 + type: integer + labelValueLengthLimit: + description: |- + Per-scrape limit on length of labels value that will be accepted for a sample. + + + It requires Prometheus >= v2.27.0. + format: int64 + type: integer + namespaceSelector: + description: |- + Selector to select which namespaces the Kubernetes `Endpoints` objects + are discovered from. + properties: + any: + description: |- + Boolean describing whether all namespaces are selected in contrast to a + list restricting them. + type: boolean + matchNames: + description: List of namespace names to select from. + items: + type: string + type: array + type: object + podTargetLabels: + description: |- + `podTargetLabels` defines the labels which are transferred from the + associated Kubernetes `Pod` object onto the ingested metrics. + items: + type: string + type: array + sampleLimit: + description: |- + `sampleLimit` defines a per-scrape limit on the number of scraped samples + that will be accepted. + format: int64 + type: integer + scrapeClass: + description: The scrape class to apply. + minLength: 1 + type: string + scrapeProtocols: + description: |- + `scrapeProtocols` defines the protocols to negotiate during a scrape. It tells clients the + protocols supported by Prometheus in order of preference (from most to least preferred). + + + If unset, Prometheus uses its default value. + + + It requires Prometheus >= v2.49.0. + items: + description: |- + ScrapeProtocol represents a protocol used by Prometheus for scraping metrics. + Supported values are: + * `OpenMetricsText0.0.1` + * `OpenMetricsText1.0.0` + * `PrometheusProto` + * `PrometheusText0.0.4` + enum: + - PrometheusProto + - OpenMetricsText0.0.1 + - OpenMetricsText1.0.0 + - PrometheusText0.0.4 + type: string + type: array + x-kubernetes-list-type: set + selector: + description: Label selector to select the Kubernetes `Endpoints` objects. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + targetLabels: + description: |- + `targetLabels` defines the labels which are transferred from the + associated Kubernetes `Service` object onto the ingested metrics. + items: + type: string + type: array + targetLimit: + description: |- + `targetLimit` defines a limit on the number of scraped targets that will + be accepted. + format: int64 + type: integer + required: + - selector + type: object + required: + - spec + type: object + served: true + storage: true diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 4a8d66d..dab9cf2 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -33,3 +33,62 @@ rules: - get - patch - update +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list + - watch +- apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - "" + resources: + - pods + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - monitoring.coreos.com + resources: + - servicemonitors + verbs: + - create + - delete + - get + - list + - patch + - update + - watch diff --git a/config/samples/agentrax_v1alpha1_agentdeployment.yaml b/config/samples/agentrax_v1alpha1_agentdeployment.yaml index bfdd8bb..50ee7a2 100644 --- a/config/samples/agentrax_v1alpha1_agentdeployment.yaml +++ b/config/samples/agentrax_v1alpha1_agentdeployment.yaml @@ -1,17 +1,68 @@ +# ────────────────────────────────────────────────────────────────────────────── +# Prerequisites for AgentDeployment/query-agent +# +# Apply in order: +# kubectl apply -f config/samples/agentrax_v1alpha1_agentdeployment.yaml +# +# The file creates: +# 1. Namespace tenant-search +# 2. TenantQuota team-search (governs quota for the AgentDeployment) +# 3. AgentDeployment query-agent (references team-search via tenantRef) +# ────────────────────────────────────────────────────────────────────────────── + +# 1. Namespace — all resources live here. +apiVersion: v1 +kind: Namespace +metadata: + name: tenant-search + labels: + app.kubernetes.io/part-of: agentrax-demo + +--- +# 2. TenantQuota — must exist before the AgentDeployment is created. +# tenantRef in the AgentDeployment below must match this resource's name. +apiVersion: agentrax.io/v1alpha1 +kind: TenantQuota +metadata: + name: team-search + namespace: tenant-search +spec: + # Maximum total replica count across all AgentDeployments in this namespace. + maxTotalReplicas: 10 + # Maximum number of distinct AgentDeployments allowed. + maxAgents: 5 + +--- +# 3. AgentDeployment — the main resource. apiVersion: agentrax.io/v1alpha1 kind: AgentDeployment metadata: name: query-agent - namespace: default + namespace: tenant-search + labels: + app.kubernetes.io/part-of: agentrax-demo spec: + # Image of the model/agent container to deploy. image: gcr.io/google-containers/echoserver:1.4 + + # Port the agent listens on (defaults to 8080 if omitted). + port: 8080 + + # tenantRef names the TenantQuota in the same namespace that governs quota. + # Must match the TenantQuota resource name above. tenantRef: team-search + + # replicas defines the autoscaling policy. replicas: min: 1 max: 3 metric: queueDepth target: 50 + + # rollout defaults to Recreate; no canary steps required for Phase 1. rollout: strategy: Recreate + + # mcp.expose: false — MCP registration handled in Phase 5. mcp: expose: false diff --git a/go.mod b/go.mod index 5d03862..19b71e9 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.22.0 require ( github.com/onsi/ginkgo/v2 v2.19.0 github.com/onsi/gomega v1.33.1 + github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.75.0 + k8s.io/api v0.31.0 + k8s.io/apiextensions-apiserver v0.31.0 k8s.io/apimachinery v0.31.0 k8s.io/client-go v0.31.0 sigs.k8s.io/controller-runtime v0.19.0 @@ -84,8 +87,6 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.31.0 // indirect - k8s.io/apiextensions-apiserver v0.31.0 // indirect k8s.io/apiserver v0.31.0 // indirect k8s.io/component-base v0.31.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect @@ -95,4 +96,4 @@ require ( sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect sigs.k8s.io/yaml v1.4.0 // indirect -) +) \ No newline at end of file diff --git a/go.sum b/go.sum index a8ec01d..8db3520 100644 --- a/go.sum +++ b/go.sum @@ -101,6 +101,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.75.0 h1:62MgqpTrtjNd8cc0RJSFJ1OHqgSrThgHehGVuQaF/fc= +github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.75.0/go.mod h1:XYrdZw5dW12Cjkt4ndbeNZZTBp4UCHtW0ccR9+sTtPU= github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= @@ -248,4 +250,4 @@ sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h6 sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= \ No newline at end of file diff --git a/internal/controller/agentdeployment_builder_test.go b/internal/controller/agentdeployment_builder_test.go new file mode 100644 index 0000000..9162660 --- /dev/null +++ b/internal/controller/agentdeployment_builder_test.go @@ -0,0 +1,347 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + agentraxv1alpha1 "github.com/gitcommitankit/agentrax/api/v1alpha1" +) + +// testAgentName is the canonical agent name used across builder unit tests. +const testAgentName = "my-agent" + +// testDefaultImage is the standard placeholder image used in builder unit tests. +const testDefaultImage = "img:v1" + +// testDefaultAgent is the default agent name used for tests not specifically +// testing label or name behaviour. +const testDefaultAgent = "agent" + +// testLabelName is the well-known label key for the resource/agent name. +const testLabelName = "app.kubernetes.io/name" + +// makeAD is a test helper that constructs a minimal AgentDeployment. +func makeAD(name, image string, port int32, minReplicas int32) *agentraxv1alpha1.AgentDeployment { + return &agentraxv1alpha1.AgentDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "tenant-test", + }, + Spec: agentraxv1alpha1.AgentDeploymentSpec{ + Image: image, + Port: port, + TenantRef: "team-test", + Replicas: agentraxv1alpha1.ScalingPolicy{ + Min: minReplicas, + Max: 3, + Metric: "queueDepth", + Target: 50, + }, + }, + } +} + +// ── desiredDeployment ───────────────────────────────────────────────────────── + +func TestDesiredDeployment_Image(t *testing.T) { + r := &AgentDeploymentReconciler{} + ad := makeAD("my-agent", "registry.io/agent:v1", 8080, 1) + dep := r.desiredDeployment(ad) + + if dep.Spec.Template.Spec.Containers[0].Image != "registry.io/agent:v1" { + t.Errorf("expected image registry.io/agent:v1, got %s", dep.Spec.Template.Spec.Containers[0].Image) + } +} + +func TestDesiredDeployment_Port(t *testing.T) { + tests := []struct { + name string + specPort int32 + wantPort int32 + }{ + {"explicit port", 9090, 9090}, + {"zero defaults to 8080", 0, 8080}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := &AgentDeploymentReconciler{} + ad := makeAD(testDefaultAgent, testDefaultImage, tc.specPort, 1) + dep := r.desiredDeployment(ad) + got := dep.Spec.Template.Spec.Containers[0].Ports[0].ContainerPort + if got != tc.wantPort { + t.Errorf("expected port %d, got %d", tc.wantPort, got) + } + }) + } +} + +func TestDesiredDeployment_Replicas(t *testing.T) { + r := &AgentDeploymentReconciler{} + ad := makeAD(testDefaultAgent, testDefaultImage, 8080, 3) + dep := r.desiredDeployment(ad) + + if *dep.Spec.Replicas != 3 { + t.Errorf("expected 3 replicas, got %d", *dep.Spec.Replicas) + } +} + +func TestDesiredDeployment_EnvAndArgs(t *testing.T) { + r := &AgentDeploymentReconciler{} + ad := makeAD(testDefaultAgent, testDefaultImage, 8080, 1) + ad.Spec.Env = []corev1.EnvVar{{Name: "MODEL_PATH", Value: "/models/v1"}} + ad.Spec.Args = []string{"--serve", "--port=8080"} + + dep := r.desiredDeployment(ad) + c := dep.Spec.Template.Spec.Containers[0] + + if len(c.Env) != 1 || c.Env[0].Name != "MODEL_PATH" { + t.Errorf("expected env MODEL_PATH, got %v", c.Env) + } + if len(c.Args) != 2 || c.Args[0] != "--serve" { + t.Errorf("expected args [--serve --port=8080], got %v", c.Args) + } +} + +func TestDesiredDeployment_Resources(t *testing.T) { + r := &AgentDeploymentReconciler{} + ad := makeAD(testDefaultAgent, testDefaultImage, 8080, 1) + want := corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceMemory: resource.MustParse("1Gi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("2"), + corev1.ResourceMemory: resource.MustParse("4Gi"), + }, + } + ad.Spec.Resources = want + + dep := r.desiredDeployment(ad) + got := dep.Spec.Template.Spec.Containers[0].Resources + + for rsrc, wantQ := range want.Requests { + gotQ := got.Requests[rsrc] + if gotQ.Cmp(wantQ) != 0 { + t.Errorf("Resources.Requests[%s]: want %s, got %s", rsrc, wantQ.String(), gotQ.String()) + } + } + for rsrc, wantQ := range want.Limits { + gotQ := got.Limits[rsrc] + if gotQ.Cmp(wantQ) != 0 { + t.Errorf("Resources.Limits[%s]: want %s, got %s", rsrc, wantQ.String(), gotQ.String()) + } + } +} + +func TestDesiredDeployment_Labels(t *testing.T) { + r := &AgentDeploymentReconciler{} + ad := makeAD(testAgentName, testDefaultImage, 8080, 1) + dep := r.desiredDeployment(ad) + + if dep.Labels[testLabelName] != testAgentName { + t.Errorf("expected label %s=%s, got %s", testLabelName, testAgentName, dep.Labels[testLabelName]) + } + if dep.Labels["app.kubernetes.io/managed-by"] != "agentrax" { + t.Errorf("expected label managed-by=agentrax, got %s", dep.Labels["app.kubernetes.io/managed-by"]) + } + if dep.Labels["agentrax.io/tenant"] != "team-test" { + t.Errorf("expected label agentrax.io/tenant=team-test, got %s", dep.Labels["agentrax.io/tenant"]) + } + // Pod template labels must match selector. + if dep.Spec.Template.Labels[testLabelName] != testAgentName { + t.Errorf("pod template labels missing %s", testLabelName) + } +} + +func TestDesiredDeployment_SelectorMatchesPodLabels(t *testing.T) { + r := &AgentDeploymentReconciler{} + ad := makeAD(testDefaultAgent, testDefaultImage, 8080, 1) + dep := r.desiredDeployment(ad) + + for k, v := range dep.Spec.Selector.MatchLabels { + if dep.Spec.Template.Labels[k] != v { + t.Errorf("selector key %s=%s not present in pod template labels", k, v) + } + } +} + +// ── desiredService ──────────────────────────────────────────────────────────── + +func TestDesiredService_Port(t *testing.T) { + tests := []struct { + name string + specPort int32 + wantPort int32 + }{ + {"explicit port", 9090, 9090}, + {"zero defaults to 8080", 0, 8080}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := &AgentDeploymentReconciler{} + ad := makeAD(testDefaultAgent, testDefaultImage, tc.specPort, 1) + svc := r.desiredService(ad) + if svc.Spec.Ports[0].Port != tc.wantPort { + t.Errorf("expected port %d, got %d", tc.wantPort, svc.Spec.Ports[0].Port) + } + }) + } +} + +func TestDesiredService_Selector(t *testing.T) { + r := &AgentDeploymentReconciler{} + ad := makeAD(testAgentName, testDefaultImage, 8080, 1) + svc := r.desiredService(ad) + + if svc.Spec.Selector[testLabelName] != testAgentName { + t.Errorf("service selector missing %s=%s", testLabelName, testAgentName) + } +} + +func TestDesiredService_ClusterIPType(t *testing.T) { + r := &AgentDeploymentReconciler{} + ad := makeAD(testDefaultAgent, testDefaultImage, 8080, 1) + svc := r.desiredService(ad) + + if svc.Spec.Type != corev1.ServiceTypeClusterIP { + t.Errorf("expected ClusterIP service type, got %s", svc.Spec.Type) + } +} + +// ── agentLabels ─────────────────────────────────────────────────────────────── + +func TestAgentLabels(t *testing.T) { + ad := &agentraxv1alpha1.AgentDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "foo"}, + Spec: agentraxv1alpha1.AgentDeploymentSpec{TenantRef: "bar"}, + } + labels := agentLabels(ad) + + expected := map[string]string{ + "app.kubernetes.io/name": "foo", + "app.kubernetes.io/managed-by": "agentrax", + "agentrax.io/tenant": "bar", + } + for k, v := range expected { + if labels[k] != v { + t.Errorf("agentLabels[%s] = %q, want %q", k, labels[k], v) + } + } +} + +// ── condition helpers ───────────────────────────────────────────────────────── + +func TestSetAndGetCondition(t *testing.T) { + ad := &agentraxv1alpha1.AgentDeployment{} + + SetCondition(ad, agentraxv1alpha1.ConditionReady, metav1.ConditionTrue, "TestReason", "all good") + c := GetCondition(ad, agentraxv1alpha1.ConditionReady) + + if c == nil { + t.Fatal("expected condition to be set, got nil") + } + if c.Status != metav1.ConditionTrue { + t.Errorf("expected ConditionTrue, got %s", c.Status) + } + if c.Reason != "TestReason" { + t.Errorf("expected reason TestReason, got %s", c.Reason) + } +} + +func TestSetCondition_Overwrite(t *testing.T) { + ad := &agentraxv1alpha1.AgentDeployment{} + + SetCondition(ad, agentraxv1alpha1.ConditionReady, metav1.ConditionFalse, "NotReady", "waiting") + SetCondition(ad, agentraxv1alpha1.ConditionReady, metav1.ConditionTrue, "Ready", "ready now") + + c := GetCondition(ad, agentraxv1alpha1.ConditionReady) + if c == nil || c.Status != metav1.ConditionTrue { + t.Errorf("expected overwrite to ConditionTrue, got %v", c) + } + // Slice must not grow with duplicate condition types. + if len(ad.Status.Conditions) != 1 { + t.Errorf("expected 1 condition after overwrite, got %d", len(ad.Status.Conditions)) + } +} + +func TestRemoveCondition(t *testing.T) { + ad := &agentraxv1alpha1.AgentDeployment{} + + SetCondition(ad, agentraxv1alpha1.ConditionReady, metav1.ConditionTrue, "R", "m") + SetCondition(ad, agentraxv1alpha1.ConditionReconciled, metav1.ConditionTrue, "R", "m") + + RemoveCondition(ad, agentraxv1alpha1.ConditionReady) + + if GetCondition(ad, agentraxv1alpha1.ConditionReady) != nil { + t.Error("expected Ready condition to be removed") + } + if GetCondition(ad, agentraxv1alpha1.ConditionReconciled) == nil { + t.Error("expected Reconciled condition to remain after removing Ready") + } +} + +func TestRemoveCondition_NonExistent(t *testing.T) { + ad := &agentraxv1alpha1.AgentDeployment{} + // Should be a no-op, not panic. + RemoveCondition(ad, agentraxv1alpha1.ConditionReady) + if len(ad.Status.Conditions) != 0 { + t.Errorf("expected 0 conditions after removing non-existent, got %d", len(ad.Status.Conditions)) + } +} + +func TestGetCondition_Absent(t *testing.T) { + ad := &agentraxv1alpha1.AgentDeployment{} + c := GetCondition(ad, agentraxv1alpha1.ConditionReady) + if c != nil { + t.Errorf("expected nil for absent condition, got %v", c) + } +} + +// ── desiredServiceMonitor ───────────────────────────────────────────────────── + +func TestDesiredServiceMonitor_Endpoint(t *testing.T) { + r := &AgentDeploymentReconciler{} + ad := makeAD(testDefaultAgent, testDefaultImage, 8080, 1) + sm := r.desiredServiceMonitor(ad) + + if len(sm.Spec.Endpoints) != 1 { + t.Fatalf("expected 1 endpoint, got %d", len(sm.Spec.Endpoints)) + } + ep := sm.Spec.Endpoints[0] + if ep.Path != "/metrics" { + t.Errorf("expected path /metrics, got %s", ep.Path) + } + if ep.Port != "agent" { + t.Errorf("expected port name 'agent', got %s", ep.Port) + } +} + +func TestDesiredServiceMonitor_SelectorMatchesLabels(t *testing.T) { + r := &AgentDeploymentReconciler{} + ad := makeAD(testAgentName, testDefaultImage, 8080, 1) + sm := r.desiredServiceMonitor(ad) + + if sm.Spec.Selector.MatchLabels[testLabelName] != testAgentName { + t.Errorf("ServiceMonitor selector missing %s=%s", testLabelName, testAgentName) + } +} diff --git a/internal/controller/agentdeployment_controller.go b/internal/controller/agentdeployment_controller.go index 95361e6..8190afc 100644 --- a/internal/controller/agentdeployment_controller.go +++ b/internal/controller/agentdeployment_controller.go @@ -19,12 +19,25 @@ package controller import ( "context" + "fmt" + "sync" + "time" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + apierrors "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" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" + "github.com/go-logr/logr" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + agentraxv1alpha1 "github.com/gitcommitankit/agentrax/api/v1alpha1" ) @@ -32,32 +45,468 @@ import ( type AgentDeploymentReconciler struct { client.Client Scheme *runtime.Scheme + + // hasServiceMonitorCRD is set once during SetupWithManager and determines + // whether ServiceMonitor reconciliation is attempted at all. + hasServiceMonitorCRD bool + + // deregisterMu guards the Deregister field so test goroutines can safely + // inject and clear the hook while the reconciler goroutine reads it. + deregisterMu sync.Mutex + + // Deregister is an optional hook called during deletion cleanup before the + // finalizer is removed. Phase 5 will set this to a real MCP deregistration + // function. In tests it can be used to assert ordering invariants. + // Always access through SetDeregister / the mutex-protected load in runDeletionCleanup. + Deregister func(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) error +} + +// SetDeregister safely replaces the Deregister hook under the mutex. +// Use this instead of direct field assignment to avoid data races. +func (r *AgentDeploymentReconciler) SetDeregister(fn func(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) error) { + r.deregisterMu.Lock() + defer r.deregisterMu.Unlock() + r.Deregister = fn } // +kubebuilder:rbac:groups=agentrax.io,resources=agentdeployments,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=agentrax.io,resources=agentdeployments/status,verbs=get;update;patch // +kubebuilder:rbac:groups=agentrax.io,resources=agentdeployments/finalizers,verbs=update +// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch +// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch +// +kubebuilder:rbac:groups=monitoring.coreos.com,resources=servicemonitors,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get;list;watch -// Reconcile is part of the main kubernetes reconciliation loop which aims to -// move the current state of the cluster closer to the desired state. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the AgentDeployment object against the actual cluster state, and then -// perform operations to make the cluster state reflect the state specified by -// the user. -// -// For more details, check Reconcile and its Result here: -// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.0/pkg/reconcile +// Reconcile drives the AgentDeployment's observed state toward its declared spec. +// It creates and self-heals a Deployment, Service, and (when Prometheus Operator is present) +// a ServiceMonitor as owned child resources, then updates status conditions. func (r *AgentDeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = log.FromContext(ctx) + logger := log.FromContext(ctx) + + // 1. Fetch the AgentDeployment; return immediately if it has been deleted. + ad := &agentraxv1alpha1.AgentDeployment{} + if err := r.Get(ctx, req.NamespacedName, ad); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, fmt.Errorf("fetching AgentDeployment: %w", err) + } + + // 2. Handle finalizer lifecycle. + if ad.DeletionTimestamp.IsZero() { + // Object is not being deleted — ensure our finalizer is present. + if !controllerutil.ContainsFinalizer(ad, agentraxv1alpha1.AgentDeploymentFinalizer) { + controllerutil.AddFinalizer(ad, agentraxv1alpha1.AgentDeploymentFinalizer) + if err := r.Update(ctx, ad); err != nil { + return ctrl.Result{}, fmt.Errorf("adding finalizer: %w", err) + } + // Re-fetch so we have the latest resourceVersion before continuing. + if err := r.Get(ctx, req.NamespacedName, ad); err != nil { + return ctrl.Result{}, fmt.Errorf("re-fetching after finalizer add: %w", err) + } + } + } else { + // Object is being deleted — run cleanup and remove finalizer. + if controllerutil.ContainsFinalizer(ad, agentraxv1alpha1.AgentDeploymentFinalizer) { + if err := r.runDeletionCleanup(ctx, ad); err != nil { + return ctrl.Result{}, fmt.Errorf("running deletion cleanup: %w", err) + } + controllerutil.RemoveFinalizer(ad, agentraxv1alpha1.AgentDeploymentFinalizer) + if err := r.Update(ctx, ad); err != nil { + return ctrl.Result{}, fmt.Errorf("removing finalizer: %w", err) + } + } + return ctrl.Result{}, nil + } - // TODO(user): your logic here + // 3. Reconcile child Deployment. + if err := r.reconcileDeployment(ctx, ad); err != nil { + return ctrl.Result{}, fmt.Errorf("reconciling deployment: %w", err) + } + + // 4. Reconcile child Service. + if err := r.reconcileService(ctx, ad); err != nil { + return ctrl.Result{}, fmt.Errorf("reconciling service: %w", err) + } + + // 5. Reconcile ServiceMonitor when Prometheus Operator is present. + if err := r.reconcileServiceMonitor(ctx, ad); err != nil { + return ctrl.Result{}, fmt.Errorf("reconciling servicemonitor: %w", err) + } + + // 6. Derive status from the live Deployment and update it — always last. + if result, err := r.updateStatus(ctx, ad, logger); err != nil || result.RequeueAfter > 0 { + return result, err + } return ctrl.Result{}, nil } +// runDeletionCleanup performs pre-deletion tasks before the finalizer is removed. +// If a Deregister hook is set on the reconciler, it is called here so that +// deregistration happens while child resources (Service, Deployment) still exist. +// Phase 5 will set Deregister to the real MCP deregistration implementation. +func (r *AgentDeploymentReconciler) runDeletionCleanup(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) error { + logger := log.FromContext(ctx) + // Load the hook under the mutex so test goroutines can safely inject/clear it + // without a data race against this reconciler goroutine. + r.deregisterMu.Lock() + deregister := r.Deregister + r.deregisterMu.Unlock() + + if deregister != nil { + if err := deregister(ctx, ad); err != nil { + return fmt.Errorf("deregistering agent: %w", err) + } + } + logger.Info("deletion cleanup complete", "name", ad.Name, "namespace", ad.Namespace) + return nil +} + +// reconcileDeployment creates or updates the Deployment owned by the AgentDeployment. +func (r *AgentDeploymentReconciler) reconcileDeployment(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) error { + desired := r.desiredDeployment(ad) + + // CreateOrUpdate requires name+namespace on existing before calling; MutateFn must not set them. + existing := &appsv1.Deployment{} + existing.Name = desired.Name + existing.Namespace = desired.Namespace + + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, existing, func() error { + existing.Labels = desired.Labels + + // Preserve the existing immutable selector on update; only set it on create + // (when ResourceVersion is empty). Overwriting spec.selector on an existing + // Deployment is rejected by the API server because it is immutable. + if existing.ResourceVersion == "" { + existing.Spec.Selector = desired.Spec.Selector + } + + // Update only the fields the controller owns; do not wholesale replace + // spec so API-defaulted values (e.g. strategy, progressDeadlineSeconds) + // are preserved. + existing.Spec.Replicas = desired.Spec.Replicas + existing.Spec.Template.Labels = desired.Spec.Template.Labels + if len(existing.Spec.Template.Spec.Containers) == 0 { + existing.Spec.Template.Spec.Containers = desired.Spec.Template.Spec.Containers + } else { + c := &existing.Spec.Template.Spec.Containers[0] + c.Image = desired.Spec.Template.Spec.Containers[0].Image + c.Ports = desired.Spec.Template.Spec.Containers[0].Ports + c.Resources = desired.Spec.Template.Spec.Containers[0].Resources + c.Env = desired.Spec.Template.Spec.Containers[0].Env + c.Args = desired.Spec.Template.Spec.Containers[0].Args + } + + if err := controllerutil.SetControllerReference(ad, existing, r.Scheme); err != nil { + return fmt.Errorf("setting controller reference: %w", err) + } + return nil + }) + if err != nil { + return fmt.Errorf("reconciling deployment: %w", err) + } + return nil +} + +// reconcileService creates or updates the Service owned by the AgentDeployment. +func (r *AgentDeploymentReconciler) reconcileService(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) error { + desired := r.desiredService(ad) + + existing := &corev1.Service{} + existing.Name = desired.Name + existing.Namespace = desired.Namespace + + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, existing, func() error { + existing.Labels = desired.Labels + // Only overwrite spec fields we own; clusterIP is assigned by the API server. + existing.Spec.Selector = desired.Spec.Selector + existing.Spec.Ports = desired.Spec.Ports + if err := controllerutil.SetControllerReference(ad, existing, r.Scheme); err != nil { + return fmt.Errorf("setting controller reference: %w", err) + } + return nil + }) + return err +} + +// reconcileServiceMonitor creates or updates the ServiceMonitor owned by the AgentDeployment. +// It is a no-op when the ServiceMonitor CRD was not present at manager startup. +func (r *AgentDeploymentReconciler) reconcileServiceMonitor(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) error { + if !r.hasServiceMonitorCRD { + return nil + } + + desired := r.desiredServiceMonitor(ad) + + existing := &monitoringv1.ServiceMonitor{} + existing.Name = desired.Name + existing.Namespace = desired.Namespace + + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, existing, func() error { + existing.Labels = desired.Labels + existing.Spec = desired.Spec + if err := controllerutil.SetControllerReference(ad, existing, r.Scheme); err != nil { + return fmt.Errorf("setting controller reference: %w", err) + } + return nil + }) + return err +} + +// updateStatus derives the AgentDeployment status from the live Deployment and writes it. +// This is always the last step in the reconcile loop. +func (r *AgentDeploymentReconciler) updateStatus(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment, logger logr.Logger) (ctrl.Result, error) { + // Re-fetch the live Deployment to get accurate replica counts. + dep := &appsv1.Deployment{} + depKey := client.ObjectKey{Name: ad.Name, Namespace: ad.Namespace} + if err := r.Get(ctx, depKey, dep); err != nil { + if apierrors.IsNotFound(err) { + // Deployment not ready yet — requeue. + return ctrl.Result{RequeueAfter: 5 * time.Second}, nil + } + return ctrl.Result{}, fmt.Errorf("fetching deployment for status: %w", err) + } + + // Re-fetch the AgentDeployment with the latest resourceVersion before patching status. + // If the object was deleted between the start of reconcile and now, treat it + // as a no-op rather than an error — the deletion path has already handled cleanup. + latest := &agentraxv1alpha1.AgentDeployment{} + if err := r.Get(ctx, client.ObjectKeyFromObject(ad), latest); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, fmt.Errorf("re-fetching agentdeployment for status update: %w", err) + } + + // Deep-copy the current status before mutating so we can compare the full + // before/after with equality.Semantic.DeepEqual. This catches intra-condition + // changes (e.g. Reason, Message, LastTransitionTime updates at the same + // condition count) that a scalar/len check would silently miss. + prevStatus := latest.Status.DeepCopy() + + latest.Status.CurrentReplicas = dep.Status.ReadyReplicas + + // Detect ImagePullBackOff by inspecting pod list; requeue if listing fails. + imagePullFailed, failMsg, err := r.detectImagePullFailure(ctx, ad) + if err != nil { + return ctrl.Result{}, fmt.Errorf("detecting image pull failure: %w", err) + } + if imagePullFailed { + SetCondition(latest, agentraxv1alpha1.ConditionImagePullFailed, metav1.ConditionTrue, "ImagePullBackOff", failMsg) + latest.Status.Phase = agentraxv1alpha1.PhaseDegraded + } else { + RemoveCondition(latest, agentraxv1alpha1.ConditionImagePullFailed) + + // A rollout is complete when the Deployment controller has observed the + // latest generation and every replica is both updated and available. + // Checking only ReadyReplicas > 0 would promote StableVersion prematurely + // while old-image pods are still serving traffic during a rolling update. + replicas := int32(1) + if dep.Spec.Replicas != nil { + replicas = *dep.Spec.Replicas + } + rolloutComplete := dep.Status.ObservedGeneration >= dep.Generation && + dep.Status.UpdatedReplicas == replicas && + dep.Status.AvailableReplicas == replicas + + if rolloutComplete { + latest.Status.Phase = agentraxv1alpha1.PhaseRunning + // Derive StableVersion from the image the Deployment controller + // applied — not from latest.Spec.Image — so it reflects what is + // actually running, even if the spec was updated again since. + if len(dep.Spec.Template.Spec.Containers) > 0 { + latest.Status.StableVersion = dep.Spec.Template.Spec.Containers[0].Image + } + SetCondition(latest, agentraxv1alpha1.ConditionReady, metav1.ConditionTrue, "DeploymentReady", "Deployment is ready") + SetCondition(latest, agentraxv1alpha1.ConditionReconciled, metav1.ConditionTrue, "ReconcileSuccess", "Latest generation reconciled") + } else { + latest.Status.Phase = agentraxv1alpha1.PhasePending + SetCondition(latest, agentraxv1alpha1.ConditionReady, metav1.ConditionFalse, "DeploymentNotReady", "Waiting for pods to become ready") + SetCondition(latest, agentraxv1alpha1.ConditionReconciled, metav1.ConditionTrue, "ReconcileSuccess", "Latest generation reconciled") + } + } + + // Only write status when something actually changed to avoid spurious API + // calls and watch events on every reconcile. + if !equality.Semantic.DeepEqual(prevStatus, &latest.Status) { + if err := r.Status().Update(ctx, latest); err != nil { + return ctrl.Result{}, fmt.Errorf("updating status: %w", err) + } + } + + // If still pending, requeue to check readiness again. + if latest.Status.Phase == agentraxv1alpha1.PhasePending { + return ctrl.Result{RequeueAfter: 5 * time.Second}, nil + } + + logger.Info("reconciled AgentDeployment", "phase", latest.Status.Phase, "readyReplicas", latest.Status.CurrentReplicas) + return ctrl.Result{}, nil +} + +// detectImagePullFailure returns true and a description message when any pod owned +// by this AgentDeployment is in ImagePullBackOff or ErrImagePull state. +// It returns an error if the pod list call fails so the caller can requeue. +func (r *AgentDeploymentReconciler) detectImagePullFailure(ctx context.Context, ad *agentraxv1alpha1.AgentDeployment) (bool, string, error) { + podList := &corev1.PodList{} + if err := r.List(ctx, podList, + client.InNamespace(ad.Namespace), + client.MatchingLabels(agentLabels(ad)), + ); err != nil { + return false, "", fmt.Errorf("listing pods for image pull check: %w", err) + } + + imagePullReasons := map[string]bool{"ImagePullBackOff": true, "ErrImagePull": true} + + for i := range podList.Items { + pod := &podList.Items[i] + for _, cs := range append(pod.Status.ContainerStatuses, pod.Status.InitContainerStatuses...) { + if cs.State.Waiting != nil && imagePullReasons[cs.State.Waiting.Reason] { + return true, fmt.Sprintf("pod %s: %s", pod.Name, cs.State.Waiting.Message), nil + } + } + } + return false, "", nil +} + +// ── Desired-state builders ──────────────────────────────────────────────────── + +// agentLabels returns the canonical label set applied to all resources owned by ad. +func agentLabels(ad *agentraxv1alpha1.AgentDeployment) map[string]string { + return map[string]string{ + "app.kubernetes.io/name": ad.Name, + "app.kubernetes.io/managed-by": "agentrax", + "agentrax.io/tenant": ad.Spec.TenantRef, + } +} + +// desiredDeployment builds the Deployment spec the reconciler wants to exist. +func (r *AgentDeploymentReconciler) desiredDeployment(ad *agentraxv1alpha1.AgentDeployment) *appsv1.Deployment { + port := ad.Spec.Port + if port == 0 { + port = 8080 + } + + labels := agentLabels(ad) + replicas := ad.Spec.Replicas.Min + + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: ad.Name, + Namespace: ad.Namespace, + Labels: labels, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: labels, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + // Use a fixed container name that is always DNS-1035 compliant. + // The AgentDeployment name is used at the object level, not container level. + Name: "agent", + Image: ad.Spec.Image, + Ports: []corev1.ContainerPort{ + { + Name: "agent", + ContainerPort: port, + Protocol: corev1.ProtocolTCP, + }, + }, + Resources: ad.Spec.Resources, + Env: ad.Spec.Env, + Args: ad.Spec.Args, + }, + }, + }, + }, + }, + } +} + +// desiredService builds the Service spec the reconciler wants to exist. +func (r *AgentDeploymentReconciler) desiredService(ad *agentraxv1alpha1.AgentDeployment) *corev1.Service { + port := ad.Spec.Port + if port == 0 { + port = 8080 + } + + labels := agentLabels(ad) + + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: ad.Name, + Namespace: ad.Namespace, + Labels: labels, + }, + Spec: corev1.ServiceSpec{ + Selector: labels, + Ports: []corev1.ServicePort{ + { + Name: "agent", + Port: port, + TargetPort: intstr.FromInt32(port), + Protocol: corev1.ProtocolTCP, + }, + }, + Type: corev1.ServiceTypeClusterIP, + }, + } +} + +// desiredServiceMonitor builds the ServiceMonitor spec that scrapes /metrics on the agent pods. +func (r *AgentDeploymentReconciler) desiredServiceMonitor(ad *agentraxv1alpha1.AgentDeployment) *monitoringv1.ServiceMonitor { + labels := agentLabels(ad) + + return &monitoringv1.ServiceMonitor{ + ObjectMeta: metav1.ObjectMeta{ + Name: ad.Name, + Namespace: ad.Namespace, + Labels: labels, + }, + Spec: monitoringv1.ServiceMonitorSpec{ + Selector: metav1.LabelSelector{ + MatchLabels: labels, + }, + Endpoints: []monitoringv1.Endpoint{ + { + Port: "agent", + Path: "/metrics", + }, + }, + }, + } +} + // SetupWithManager sets up the controller with the Manager. +// It uses an uncached API reader to check once whether the ServiceMonitor CRD is +// installed, stores the result on the reconciler, and conditionally adds an +// Owns watch for ServiceMonitor so that out-of-band deletions trigger a reconcile. func (r *AgentDeploymentReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). + // Check CRD presence once at startup using the uncached reader so we don't + // require apiextensionsv1 to be registered in the caching informer scheme. + var err error + r.hasServiceMonitorCRD, err = serviceMonitorCRDExists(context.Background(), mgr.GetAPIReader()) + if err != nil { + return fmt.Errorf("checking servicemonitor CRD at setup: %w", err) + } + + bldr := ctrl.NewControllerManagedBy(mgr). For(&agentraxv1alpha1.AgentDeployment{}). - Complete(r) + Owns(&appsv1.Deployment{}). + Owns(&corev1.Service{}) + + if r.hasServiceMonitorCRD { + bldr = bldr.Owns(&monitoringv1.ServiceMonitor{}) + } + + return bldr.Complete(r) } diff --git a/internal/controller/agentdeployment_controller_test.go b/internal/controller/agentdeployment_controller_test.go index c267bb9..4160556 100644 --- a/internal/controller/agentdeployment_controller_test.go +++ b/internal/controller/agentdeployment_controller_test.go @@ -18,76 +18,649 @@ package controller import ( "context" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/reconcile" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "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" + + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" agentraxv1alpha1 "github.com/gitcommitankit/agentrax/api/v1alpha1" ) -var _ = Describe("AgentDeployment Controller", func() { - Context("When reconciling a resource", func() { - const resourceName = "test-resource" +const ( + // testTimeout is the maximum time any Eventually assertion waits. + testTimeout = 30 * time.Second + // testInterval is how often Eventually polls. + testInterval = 250 * time.Millisecond + // testNginxImage is the standard container image used in integration tests. + testNginxImage = "nginx:latest" +) - ctx := context.Background() +// createAgentDeployment is a test helper that creates a minimal AgentDeployment +// and returns its NamespacedName. +func createAgentDeployment(name, namespace, image string, port, minReplicas int32) types.NamespacedName { + ad := &agentraxv1alpha1.AgentDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: agentraxv1alpha1.AgentDeploymentSpec{ + Image: image, + Port: port, + TenantRef: "team-test", + Replicas: agentraxv1alpha1.ScalingPolicy{ + Min: minReplicas, + Max: 3, + Metric: "queueDepth", + Target: 50, + }, + }, + } + Expect(k8sClient.Create(ctx, ad)).To(Succeed()) + return types.NamespacedName{Name: name, Namespace: namespace} +} - typeNamespacedName := types.NamespacedName{ - Name: resourceName, - Namespace: "default", // TODO(user):Modify as needed +// deleteAgentDeployment deletes an AgentDeployment and waits for it to be gone. +func deleteAgentDeployment(key types.NamespacedName) { + ad := &agentraxv1alpha1.AgentDeployment{} + if err := k8sClient.Get(ctx, key, ad); err != nil { + if apierrors.IsNotFound(err) { + return } - agentdeployment := &agentraxv1alpha1.AgentDeployment{} + Expect(err).NotTo(HaveOccurred()) + } + Expect(k8sClient.Delete(ctx, ad)).To(Succeed()) + + // Wait for the object to be fully deleted (finalizer removed). + Eventually(func() bool { + err := k8sClient.Get(ctx, key, &agentraxv1alpha1.AgentDeployment{}) + return apierrors.IsNotFound(err) + }, testTimeout, testInterval).Should(BeTrue(), "AgentDeployment should be fully deleted") +} + +// deleteChildResources explicitly deletes child Deployment, Service, and +// ServiceMonitor objects with the given key. +// Envtest does not run the Kubernetes GC controller, so owner-reference-based +// cascading deletion never fires; tests that share a namespace must clean up +// children themselves to prevent stale owner UIDs from bleeding into sibling +// specs. +func deleteChildResources(key types.NamespacedName) { + dep := &appsv1.Deployment{} + if err := k8sClient.Get(ctx, key, dep); err == nil { + _ = k8sClient.Delete(ctx, dep) + Eventually(func() bool { + return apierrors.IsNotFound(k8sClient.Get(ctx, key, &appsv1.Deployment{})) + }, testTimeout, testInterval).Should(BeTrue(), "child Deployment should be deleted") + } + + svc := &corev1.Service{} + if err := k8sClient.Get(ctx, key, svc); err == nil { + _ = k8sClient.Delete(ctx, svc) + Eventually(func() bool { + return apierrors.IsNotFound(k8sClient.Get(ctx, key, &corev1.Service{})) + }, testTimeout, testInterval).Should(BeTrue(), "child Service should be deleted") + } + + sm := &monitoringv1.ServiceMonitor{} + if err := k8sClient.Get(ctx, key, sm); err == nil { + _ = k8sClient.Delete(ctx, sm) + Eventually(func() bool { + return apierrors.IsNotFound(k8sClient.Get(ctx, key, &monitoringv1.ServiceMonitor{})) + }, testTimeout, testInterval).Should(BeTrue(), "child ServiceMonitor should be deleted") + } +} + +var _ = Describe("AgentDeployment Controller", func() { + + // Each Describe block uses a unique namespace to avoid cross-test interference. + + Describe("Finalizer lifecycle", func() { + var key types.NamespacedName BeforeEach(func() { - By("creating the custom resource for the Kind AgentDeployment") - err := k8sClient.Get(ctx, typeNamespacedName, agentdeployment) - if err != nil && errors.IsNotFound(err) { - resource := &agentraxv1alpha1.AgentDeployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: "default", - }, - Spec: agentraxv1alpha1.AgentDeploymentSpec{ - Image: "gcr.io/google-containers/echoserver:1.4", - TenantRef: "team-search", - Replicas: agentraxv1alpha1.ScalingPolicy{ - Min: 1, - Max: 3, - Metric: "queueDepth", - Target: 50, - }, - }, + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-finalizer"}} + err := k8sClient.Create(ctx, ns) + Expect(err == nil || apierrors.IsAlreadyExists(err)).To(BeTrue(), "creating namespace test-finalizer: %v", err) + key = createAgentDeployment("ad-finalizer", "test-finalizer", testNginxImage, 8080, 1) + }) + + AfterEach(func() { + deleteAgentDeployment(key) + }) + + It("adds the finalizer on creation", func() { + ad := &agentraxv1alpha1.AgentDeployment{} + Eventually(func() bool { + if err := k8sClient.Get(ctx, key, ad); err != nil { + return false + } + for _, f := range ad.Finalizers { + if f == agentraxv1alpha1.AgentDeploymentFinalizer { + return true + } + } + return false + }, testTimeout, testInterval).Should(BeTrue(), "finalizer should be added") + }) + + It("removes the finalizer on deletion, allowing the object to be fully deleted", func() { + // Ensure finalizer is present first. + ad := &agentraxv1alpha1.AgentDeployment{} + Eventually(func() bool { + if err := k8sClient.Get(ctx, key, ad); err != nil { + return false + } + for _, f := range ad.Finalizers { + if f == agentraxv1alpha1.AgentDeploymentFinalizer { + return true + } + } + return false + }, testTimeout, testInterval).Should(BeTrue()) + + // Wait for the child Service to exist before we delete the parent. + Eventually(func() error { + return k8sClient.Get(ctx, key, &corev1.Service{}) + }, testTimeout, testInterval).Should(Succeed(), "child Service should exist before deletion") + + // Inject a Deregister hook. A buffered channel is used so the hook + // (called on the reconciler goroutine) can pass its observation to the + // test goroutine without a data race on plain booleans. + resultCh := make(chan bool, 1) + testReconciler.SetDeregister(func(hctx context.Context, had *agentraxv1alpha1.AgentDeployment) error { + err := k8sClient.Get(hctx, key, &corev1.Service{}) + resultCh <- (err == nil) + return nil + }) + DeferCleanup(func() { testReconciler.SetDeregister(nil) }) + + // Delete the object — the reconciler must call Deregister, then remove the finalizer. + Expect(k8sClient.Delete(ctx, ad)).To(Succeed()) + Eventually(func() bool { + err := k8sClient.Get(ctx, key, &agentraxv1alpha1.AgentDeployment{}) + return apierrors.IsNotFound(err) + }, testTimeout, testInterval).Should(BeTrue(), "object should be gone once finalizer removed") + + // Receive the hook result. By the time the AD is fully deleted the hook + // has already sent, so this receive never blocks. + var serviceExistedDuringDeregister bool + Eventually(resultCh, testTimeout, testInterval).Should(Receive(&serviceExistedDuringDeregister)) + + // Assert deregistration happened while the Service was still alive. + Expect(serviceExistedDuringDeregister).To(BeTrue(), "Service should exist during deregistration (before GC)") + + // NOTE: envtest does not run the Kubernetes garbage-collection controller, + // so owner-reference-based cascading deletion of the child Service cannot + // be verified here. The ordering invariant above (deregistration runs while + // the Service is still alive) is the critical correctness property. GC + // ordering is covered by a real-cluster e2e test in Phase 6. + }) + }) + + Describe("Child resource creation", func() { + var key types.NamespacedName + + BeforeEach(func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-children"}} + err := k8sClient.Create(ctx, ns) + Expect(err == nil || apierrors.IsAlreadyExists(err)).To(BeTrue(), "creating namespace test-children: %v", err) + key = createAgentDeployment("ad-children", "test-children", testNginxImage, 9090, 1) + }) + + AfterEach(func() { + deleteAgentDeployment(key) + // Explicitly delete child resources; envtest does not run the GC + // controller, so owner-reference cascading deletion never fires. + deleteChildResources(key) + }) + + It("creates a Deployment with correct image and port", func() { + dep := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, key, dep) + }, testTimeout, testInterval).Should(Succeed(), "Deployment should be created") + + Expect(dep.Spec.Template.Spec.Containers).To(HaveLen(1)) + Expect(dep.Spec.Template.Spec.Containers[0].Image).To(Equal(testNginxImage)) + Expect(dep.Spec.Template.Spec.Containers[0].Ports[0].ContainerPort).To(Equal(int32(9090))) + }) + + It("creates a Deployment with owner reference pointing to the AgentDeployment", func() { + // Fetch parent and child together inside Eventually so we wait for the + // reconciler to update the owner reference when the namespace is reused + // across test runs (envtest does not GC child objects between runs). + dep := &appsv1.Deployment{} + parent := &agentraxv1alpha1.AgentDeployment{} + Eventually(func() bool { + if err := k8sClient.Get(ctx, key, parent); err != nil { + return false + } + if err := k8sClient.Get(ctx, key, dep); err != nil { + return false + } + if len(dep.OwnerReferences) != 1 { + return false + } + return dep.OwnerReferences[0].UID == parent.UID + }, testTimeout, testInterval).Should(BeTrue(), "Deployment owner UID should converge to parent UID") + + Expect(dep.OwnerReferences[0].Name).To(Equal(key.Name)) + Expect(dep.OwnerReferences[0].Kind).To(Equal("AgentDeployment")) + Expect(dep.OwnerReferences[0].Controller).NotTo(BeNil()) + Expect(*dep.OwnerReferences[0].Controller).To(BeTrue(), "owner reference must have Controller=true") + }) + + It("creates a Service targeting the correct port", func() { + svc := &corev1.Service{} + Eventually(func() error { + return k8sClient.Get(ctx, key, svc) + }, testTimeout, testInterval).Should(Succeed(), "Service should be created") + + Expect(svc.Spec.Ports).To(HaveLen(1)) + Expect(svc.Spec.Ports[0].Port).To(Equal(int32(9090))) + Expect(svc.Spec.Selector["app.kubernetes.io/name"]).To(Equal(key.Name)) + }) + + It("creates a Service with owner reference pointing to the AgentDeployment", func() { + // Fetch parent and child together inside Eventually so we wait for the + // reconciler to update the owner reference when the namespace is reused + // across test runs (envtest does not GC child objects between runs). + svc := &corev1.Service{} + parent := &agentraxv1alpha1.AgentDeployment{} + Eventually(func() bool { + if err := k8sClient.Get(ctx, key, parent); err != nil { + return false + } + if err := k8sClient.Get(ctx, key, svc); err != nil { + return false + } + if len(svc.OwnerReferences) != 1 { + return false + } + return svc.OwnerReferences[0].UID == parent.UID + }, testTimeout, testInterval).Should(BeTrue(), "Service owner UID should converge to parent UID") + + Expect(svc.OwnerReferences[0].Name).To(Equal(key.Name)) + Expect(svc.OwnerReferences[0].Kind).To(Equal("AgentDeployment")) + Expect(svc.OwnerReferences[0].Controller).NotTo(BeNil()) + Expect(*svc.OwnerReferences[0].Controller).To(BeTrue(), "owner reference must have Controller=true") + }) + + It("creates a ServiceMonitor with owner reference pointing to the AgentDeployment", func() { + // This test exercises the hasServiceMonitorCRD=true code path, which is + // enabled by loading the ServiceMonitor CRD into envtest via + // config/crd/external/monitoring.coreos.com_servicemonitors.yaml. + sm := &monitoringv1.ServiceMonitor{} + parent := &agentraxv1alpha1.AgentDeployment{} + Eventually(func() bool { + if err := k8sClient.Get(ctx, key, parent); err != nil { + return false + } + if err := k8sClient.Get(ctx, key, sm); err != nil { + return false + } + if len(sm.OwnerReferences) != 1 { + return false + } + return sm.OwnerReferences[0].UID == parent.UID + }, testTimeout, testInterval).Should(BeTrue(), "ServiceMonitor owner UID should converge to parent UID") + + Expect(sm.OwnerReferences[0].Name).To(Equal(key.Name)) + Expect(sm.OwnerReferences[0].Kind).To(Equal("AgentDeployment")) + Expect(sm.OwnerReferences[0].Controller).NotTo(BeNil()) + Expect(*sm.OwnerReferences[0].Controller).To(BeTrue(), "ServiceMonitor owner reference must have Controller=true") + }) + + It("sets status.phase to Pending initially (no running pods in envtest)", func() { + ad := &agentraxv1alpha1.AgentDeployment{} + // Envtest never runs real pods so the phase must settle on Pending. + Eventually(func() string { + if err := k8sClient.Get(ctx, key, ad); err != nil { + return "" + } + return ad.Status.Phase + }, testTimeout, testInterval).Should(Equal(agentraxv1alpha1.PhasePending), "status.phase should be Pending") + }) + + It("sets the Reconciled condition", func() { + ad := &agentraxv1alpha1.AgentDeployment{} + Eventually(func() bool { + if err := k8sClient.Get(ctx, key, ad); err != nil { + return false + } + c := GetCondition(ad, agentraxv1alpha1.ConditionReconciled) + return c != nil && c.Status == metav1.ConditionTrue + }, testTimeout, testInterval).Should(BeTrue(), "Reconciled condition should be True") + }) + }) + + Describe("Image update propagation", func() { + var key types.NamespacedName + + BeforeEach(func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-image-update"}} + err := k8sClient.Create(ctx, ns) + Expect(err == nil || apierrors.IsAlreadyExists(err)).To(BeTrue(), "creating namespace test-image-update: %v", err) + key = createAgentDeployment("ad-image-update", "test-image-update", "nginx:1.24", 8080, 1) + + // Wait for Deployment to be created before proceeding. + dep := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, key, dep) + }, testTimeout, testInterval).Should(Succeed()) + }) + + AfterEach(func() { + deleteAgentDeployment(key) + }) + + It("updates the child Deployment image when spec.image changes", func() { + // Update the image field. + ad := &agentraxv1alpha1.AgentDeployment{} + Expect(k8sClient.Get(ctx, key, ad)).To(Succeed()) + ad.Spec.Image = "nginx:1.25" + Expect(k8sClient.Update(ctx, ad)).To(Succeed()) + + // Verify the child Deployment picks up the new image. + Eventually(func() string { + dep := &appsv1.Deployment{} + if err := k8sClient.Get(ctx, key, dep); err != nil { + return "" + } + if len(dep.Spec.Template.Spec.Containers) == 0 { + return "" + } + return dep.Spec.Template.Spec.Containers[0].Image + }, testTimeout, testInterval).Should(Equal("nginx:1.25"), "child Deployment image should be updated") + }) + + It("does not advance StableVersion during a partial rollout", func() { + // Record the StableVersion before the image change. In envtest no pods + // run, so the initial StableVersion is empty (rollout never completes). + // After updating the image the Deployment generation advances but + // ObservedGeneration / UpdatedReplicas / AvailableReplicas never satisfy + // the rollout-complete gate — so StableVersion must stay empty. + ad := &agentraxv1alpha1.AgentDeployment{} + Expect(k8sClient.Get(ctx, key, ad)).To(Succeed()) + stableVersionBefore := ad.Status.StableVersion + + ad.Spec.Image = "nginx:1.25" + Expect(k8sClient.Update(ctx, ad)).To(Succeed()) + + // Wait for the Deployment spec to reflect the new image so we know the + // reconciler has processed the update. + Eventually(func() string { + dep := &appsv1.Deployment{} + if err := k8sClient.Get(ctx, key, dep); err != nil { + return "" + } + if len(dep.Spec.Template.Spec.Containers) == 0 { + return "" + } + return dep.Spec.Template.Spec.Containers[0].Image + }, testTimeout, testInterval).Should(Equal("nginx:1.25"), "Deployment spec image should be updated") + + // Give the reconciler a moment to run and potentially update status, + // then assert StableVersion has not advanced beyond its pre-update value. + Consistently(func() string { + current := &agentraxv1alpha1.AgentDeployment{} + if err := k8sClient.Get(ctx, key, current); err != nil { + return "" + } + return current.Status.StableVersion + }, 2*time.Second, testInterval).Should(Equal(stableVersionBefore), + "StableVersion must not advance while rollout is incomplete") + }) + }) + + Describe("Self-healing", func() { + var key types.NamespacedName + + BeforeEach(func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-self-heal"}} + err := k8sClient.Create(ctx, ns) + Expect(err == nil || apierrors.IsAlreadyExists(err)).To(BeTrue(), "creating namespace test-self-heal: %v", err) + key = createAgentDeployment("ad-self-heal", "test-self-heal", testNginxImage, 8080, 1) + + // Wait for Deployment to be created. + dep := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, key, dep) + }, testTimeout, testInterval).Should(Succeed()) + }) + + AfterEach(func() { + deleteAgentDeployment(key) + }) + + It("recreates the child Deployment when it is deleted out-of-band", func() { + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, key, dep)).To(Succeed()) + origUID := dep.UID + + // Delete the Deployment out-of-band. + Expect(k8sClient.Delete(ctx, dep)).To(Succeed()) + + // The Owns() watch on Deployment should trigger a reconcile. + // Verify it is recreated with a new UID — not the pre-deletion object. + Eventually(func() bool { + newDep := &appsv1.Deployment{} + if err := k8sClient.Get(ctx, key, newDep); err != nil { + return false } - Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + return newDep.UID != origUID + }, testTimeout, testInterval).Should(BeTrue(), "Deployment should be self-healed with a new UID") + }) + + It("recreates the child Service when it is deleted out-of-band", func() { + svc := &corev1.Service{} + Expect(k8sClient.Get(ctx, key, svc)).To(Succeed()) + origUID := svc.UID + + Expect(k8sClient.Delete(ctx, svc)).To(Succeed()) + + Eventually(func() bool { + newSvc := &corev1.Service{} + if err := k8sClient.Get(ctx, key, newSvc); err != nil { + return false + } + return newSvc.UID != origUID + }, testTimeout, testInterval).Should(BeTrue(), "Service should be self-healed with a new UID") + }) + }) + + Describe("Label consistency", func() { + var key types.NamespacedName + + BeforeEach(func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-labels"}} + err := k8sClient.Create(ctx, ns) + Expect(err == nil || apierrors.IsAlreadyExists(err)).To(BeTrue(), "creating namespace test-labels: %v", err) + key = createAgentDeployment("ad-labels", "test-labels", testNginxImage, 8080, 1) + }) + + AfterEach(func() { + deleteAgentDeployment(key) + }) + + It("creates all child resources with managed-by=agentrax label", func() { + dep := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, key, dep) + }, testTimeout, testInterval).Should(Succeed()) + Expect(dep.Labels["app.kubernetes.io/managed-by"]).To(Equal("agentrax")) + + svc := &corev1.Service{} + Eventually(func() error { + return k8sClient.Get(ctx, key, svc) + }, testTimeout, testInterval).Should(Succeed()) + Expect(svc.Labels["app.kubernetes.io/managed-by"]).To(Equal("agentrax")) + }) + + It("child resource selector labels match pod template labels", func() { + dep := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, key, dep) + }, testTimeout, testInterval).Should(Succeed()) + + for k, v := range dep.Spec.Selector.MatchLabels { + Expect(dep.Spec.Template.Labels[k]).To(Equal(v), + "pod template label %s should match selector", k) } }) + It("Service selector matches Deployment pod template labels", func() { + dep := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, key, dep) + }, testTimeout, testInterval).Should(Succeed()) + + svc := &corev1.Service{} + Eventually(func() error { + return k8sClient.Get(ctx, key, svc) + }, testTimeout, testInterval).Should(Succeed()) + + for k, v := range svc.Spec.Selector { + Expect(dep.Spec.Template.Labels[k]).To(Equal(v), + "Service selector label %s should match Deployment pod template", k) + } + }) + }) + + Describe("Multiple AgentDeployments in same namespace", func() { + var key1, key2 types.NamespacedName + + BeforeEach(func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-multi"}} + err := k8sClient.Create(ctx, ns) + Expect(err == nil || apierrors.IsAlreadyExists(err)).To(BeTrue(), "creating namespace test-multi: %v", err) + key1 = createAgentDeployment("ad-alpha", "test-multi", "nginx:1.24", 8080, 1) + key2 = createAgentDeployment("ad-beta", "test-multi", "nginx:1.25", 9090, 2) + }) + + AfterEach(func() { + deleteAgentDeployment(key1) + deleteAgentDeployment(key2) + }) + + It("creates independent Deployments for each AgentDeployment", func() { + dep1 := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, key1, dep1) + }, testTimeout, testInterval).Should(Succeed()) + + dep2 := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, key2, dep2) + }, testTimeout, testInterval).Should(Succeed()) + + Expect(dep1.Spec.Template.Spec.Containers[0].Image).To(Equal("nginx:1.24")) + Expect(dep2.Spec.Template.Spec.Containers[0].Image).To(Equal("nginx:1.25")) + Expect(*dep2.Spec.Replicas).To(Equal(int32(2))) + }) + + It("deleting one AgentDeployment does not affect the other's children", func() { + // Ensure both Deployments exist. + Eventually(func() error { + return k8sClient.Get(ctx, key1, &appsv1.Deployment{}) + }, testTimeout, testInterval).Should(Succeed()) + Eventually(func() error { + return k8sClient.Get(ctx, key2, &appsv1.Deployment{}) + }, testTimeout, testInterval).Should(Succeed()) + + // Delete the first one. + deleteAgentDeployment(key1) + + // The second's Deployment must persist for a sustained window. + Consistently(func() error { + return k8sClient.Get(ctx, key2, &appsv1.Deployment{}) + }, 3*time.Second, testInterval).Should(Succeed(), "key2's Deployment should not be affected by key1 deletion") + }) + }) + + Describe("Port defaulting", func() { + var key types.NamespacedName + + BeforeEach(func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-port-default"}} + err := k8sClient.Create(ctx, ns) + Expect(err == nil || apierrors.IsAlreadyExists(err)).To(BeTrue(), "creating namespace test-port-default: %v", err) + // Create with port=0 (omitted) to test the default. + key = createAgentDeployment("ad-port-default", "test-port-default", testNginxImage, 0, 1) + }) + AfterEach(func() { - // TODO(user): Cleanup logic after each test, like removing the resource instance. - resource := &agentraxv1alpha1.AgentDeployment{} - err := k8sClient.Get(ctx, typeNamespacedName, resource) - Expect(err).NotTo(HaveOccurred()) - - By("Cleanup the specific resource instance AgentDeployment") - Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) - }) - It("should successfully reconcile the resource", func() { - By("Reconciling the created resource") - controllerReconciler := &AgentDeploymentReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + deleteAgentDeployment(key) + }) + + It("defaults the container port to 8080 when spec.port is zero", func() { + dep := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, key, dep) + }, testTimeout, testInterval).Should(Succeed()) + + Expect(dep.Spec.Template.Spec.Containers[0].Ports[0].ContainerPort).To(Equal(int32(8080))) + }) + + It("defaults the Service port to 8080 when spec.port is zero", func() { + svc := &corev1.Service{} + Eventually(func() error { + return k8sClient.Get(ctx, key, svc) + }, testTimeout, testInterval).Should(Succeed()) + + Expect(svc.Spec.Ports[0].Port).To(Equal(int32(8080))) + }) + }) + + Describe("Env and Args propagation", func() { + var key types.NamespacedName + + BeforeEach(func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-env-args"}} + err := k8sClient.Create(ctx, ns) + Expect(err == nil || apierrors.IsAlreadyExists(err)).To(BeTrue(), "creating namespace test-env-args: %v", err) + + ad := &agentraxv1alpha1.AgentDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ad-env-args", + Namespace: "test-env-args", + }, + Spec: agentraxv1alpha1.AgentDeploymentSpec{ + Image: testNginxImage, + Port: 8080, + TenantRef: "team-test", + Replicas: agentraxv1alpha1.ScalingPolicy{ + Min: 1, Max: 3, Metric: "queueDepth", Target: 50, + }, + Env: []corev1.EnvVar{{Name: "MODEL", Value: "gpt4"}}, + Args: []string{"--serve", "--workers=4"}, + }, } + Expect(k8sClient.Create(ctx, ad)).To(Succeed()) + key = client.ObjectKeyFromObject(ad) + }) - _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, - }) - Expect(err).NotTo(HaveOccurred()) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. + AfterEach(func() { + deleteAgentDeployment(key) + }) + + It("propagates env vars and args to the child Deployment container", func() { + dep := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, key, dep) + }, testTimeout, testInterval).Should(Succeed()) + + c := dep.Spec.Template.Spec.Containers[0] + Expect(c.Env).To(ContainElement(corev1.EnvVar{Name: "MODEL", Value: "gpt4"})) + Expect(c.Args).To(Equal([]string{"--serve", "--workers=4"})) }) }) }) diff --git a/internal/controller/conditions.go b/internal/controller/conditions.go new file mode 100644 index 0000000..ab25f76 --- /dev/null +++ b/internal/controller/conditions.go @@ -0,0 +1,48 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + agentraxv1alpha1 "github.com/gitcommitankit/agentrax/api/v1alpha1" +) + +// SetCondition sets a status condition on the AgentDeployment using the standard +// meta.SetStatusCondition helper. The ObservedGeneration is always stamped from +// the object's current generation so consumers can detect stale conditions. +func SetCondition(ad *agentraxv1alpha1.AgentDeployment, condType string, status metav1.ConditionStatus, reason, msg string) { + apimeta.SetStatusCondition(&ad.Status.Conditions, metav1.Condition{ + Type: condType, + Status: status, + Reason: reason, + Message: msg, + ObservedGeneration: ad.Generation, + }) +} + +// GetCondition returns a pointer to the named condition, or nil if absent. +func GetCondition(ad *agentraxv1alpha1.AgentDeployment, condType string) *metav1.Condition { + return apimeta.FindStatusCondition(ad.Status.Conditions, condType) +} + +// RemoveCondition removes the named condition from the AgentDeployment status slice. +// It is a no-op if the condition does not exist. +func RemoveCondition(ad *agentraxv1alpha1.AgentDeployment, condType string) { + apimeta.RemoveStatusCondition(&ad.Status.Conditions, condType) +} diff --git a/internal/controller/crd_check.go b/internal/controller/crd_check.go new file mode 100644 index 0000000..8bd1b85 --- /dev/null +++ b/internal/controller/crd_check.go @@ -0,0 +1,44 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// serviceMonitorCRDName is the fully-qualified CRD name for Prometheus Operator's ServiceMonitor. +const serviceMonitorCRDName = "servicemonitors.monitoring.coreos.com" + +// serviceMonitorCRDExists reports whether the ServiceMonitor CRD is installed in the cluster. +// It accepts a client.Reader so callers can pass either a caching client or an +// uncached API reader (mgr.GetAPIReader()). When Prometheus Operator is absent, +// the reconciler skips ServiceMonitor creation rather than erroring out. +func serviceMonitorCRDExists(ctx context.Context, r client.Reader) (bool, error) { + crd := &apiextensionsv1.CustomResourceDefinition{} + err := r.Get(ctx, client.ObjectKey{Name: serviceMonitorCRDName}, crd) + if err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + return false, err + } + return true, nil +} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index b8fde0b..5049429 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -26,12 +26,19 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" agentraxv1alpha1 "github.com/gitcommitankit/agentrax/api/v1alpha1" // +kubebuilder:scaffold:imports @@ -45,10 +52,15 @@ var k8sClient client.Client var testEnv *envtest.Environment var ctx context.Context var cancel context.CancelFunc +var mgrDone chan struct{} + +// testReconciler is the AgentDeploymentReconciler registered with the manager. +// Tests that need to inject a Deregister hook can set it before the action +// and clear it in AfterEach to avoid contaminating other tests. +var testReconciler *AgentDeploymentReconciler func TestControllers(t *testing.T) { RegisterFailHandler(Fail) - RunSpecs(t, "Controller Suite") } @@ -59,11 +71,16 @@ var _ = BeforeSuite(func() { By("bootstrapping test environment") testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "config", "crd", "bases"), + // External CRDs vendored from upstream for integration testing. + // ServiceMonitor CRD sourced from prometheus-operator v0.75.0. + filepath.Join("..", "..", "config", "crd", "external"), + }, ErrorIfCRDPathMissing: true, // The BinaryAssetsDirectory is only required if you want to run the tests directly - // without call the makefile target test. If not informed it will look for the + // without calling the makefile target test. If not informed it will look for the // default path defined in controller-runtime which is /usr/local/kubebuilder/. // Note that you must have the required binaries setup under the bin directory to perform // the tests directly. When we run make test it will be setup and used automatically. @@ -72,13 +89,19 @@ var _ = BeforeSuite(func() { } var err error - // cfg is defined in this file globally. cfg, err = testEnv.Start() Expect(err).NotTo(HaveOccurred()) Expect(cfg).NotTo(BeNil()) - err = agentraxv1alpha1.AddToScheme(scheme.Scheme) - Expect(err).NotTo(HaveOccurred()) + // Register our CRD types and core Kubernetes types. + Expect(agentraxv1alpha1.AddToScheme(scheme.Scheme)).To(Succeed()) + Expect(appsv1.AddToScheme(scheme.Scheme)).To(Succeed()) + Expect(corev1.AddToScheme(scheme.Scheme)).To(Succeed()) + // Register prometheus-operator types so the reconciler can handle ServiceMonitor objects. + Expect(monitoringv1.AddToScheme(scheme.Scheme)).To(Succeed()) + // Register apiextensions types so serviceMonitorCRDExists can decode CRD objects + // when called from SetupWithManager via the uncached API reader. + Expect(apiextensionsv1.AddToScheme(scheme.Scheme)).To(Succeed()) // +kubebuilder:scaffold:scheme @@ -86,11 +109,32 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) Expect(k8sClient).NotTo(BeNil()) + // Start the controller manager so the reconciler runs during integration tests. + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + // Disable the metrics server in tests to avoid port conflicts. + Metrics: metricsserver.Options{BindAddress: "0"}, + }) + Expect(err).NotTo(HaveOccurred()) + + testReconciler = &AgentDeploymentReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + } + Expect(testReconciler.SetupWithManager(mgr)).To(Succeed()) + + mgrDone = make(chan struct{}) + go func() { + defer GinkgoRecover() + defer close(mgrDone) + Expect(mgr.Start(ctx)).To(Succeed()) + }() }) var _ = AfterSuite(func() { By("tearing down the test environment") cancel() - err := testEnv.Stop() - Expect(err).NotTo(HaveOccurred()) + // Wait for the manager goroutine to finish before stopping envtest. + <-mgrDone + Expect(testEnv.Stop()).To(Succeed()) })