diff --git a/argocd/README.md b/argocd/README.md index 30e7965d16d93..19fbe0ae460ea 100644 --- a/argocd/README.md +++ b/argocd/README.md @@ -165,6 +165,45 @@ See the [Autodiscovery Integration Templates][3] for guidance on applying the pa [Run the Agent's status subcommand][6] and look for `argocd` under the Checks section. +### Generic resources (Beta) + +The Argo CD check can ship `Application`, `Cluster`, and `Repository` objects to Datadog as generic resources. The feature is disabled by default and is opt-in per instance. + +To enable it, set the following in your `argocd.d/conf.yaml`: + +```yaml +instances: + - app_controller_endpoint: http://argocd-metrics:8082/metrics + collect_genresources: true + generic_resources_endpoint: https:// + generic_resources_auth_token: +``` + +The token used by the collector needs `get` and `list` permissions on `applications`, `clusters`, and `repositories` in Argo CD. For example, with the built-in RBAC system: + +``` +p, role:datadog-genresources, applications, get, */*, allow +p, role:datadog-genresources, applications, list, */*, allow +p, role:datadog-genresources, clusters, get, *, allow +p, role:datadog-genresources, clusters, list, *, allow +p, role:datadog-genresources, repositories, get, *, allow +p, role:datadog-genresources, repositories, list, *, allow +``` + +`generic_resources_auth_token` is an optional raw bearer token. When set, the collector adds an `Authorization: Bearer ` header to each REST request. Leave it unset to inherit whatever request authentication is already configured on the instance. + +Operator-tunable options: + +- `genresources_ttl_seconds` (default `21600`): time-to-live applied to each emitted resource. Resources expire `ttl_seconds` after the last observation. +- `max_resources_per_cycle` (default `10000`): per-cycle cap, applied independently to each resource type. When an Argo CD API endpoint returns more, the excess is dropped and a warning is logged. +- `extra_redaction_paths` (default `[]`): additional JSON paths appended to the built-in redaction deny-list. The list is additive; it cannot remove paths from the baseline. + +Behavioral notes: + +- On every Agent restart with `collect_genresources` enabled, the collector re-emits every `Application`, `Cluster`, and `Repository` on its first cycle. The burst is bounded by `max_resources_per_cycle` (applied per type) and self-corrects on subsequent cycles. +- Disabling `collect_genresources` does not immediately delete previously-emitted resources. Resources expire on their own via `expire_at` (default 6 hours after the last observation). +- Argo CD REST API reachability is reported as the `argocd.genresources.api.up` gauge tagged with `resource_type:argocd_application`, `resource_type:argocd_cluster`, or `resource_type:argocd_repository`. The gauge is `1` when the endpoint returns a successful response and `0` when it errors, so failures in one endpoint do not mask the health of the others. + ## Data Collected ### Metrics diff --git a/argocd/assets/configuration/spec.yaml b/argocd/assets/configuration/spec.yaml index aa68842983f2d..8585241b39166 100644 --- a/argocd/assets/configuration/spec.yaml +++ b/argocd/assets/configuration/spec.yaml @@ -55,6 +55,65 @@ files: display_default: null example: http://argocd-commit-server:8087/metrics type: string + - name: collect_genresources + description: | + Enable the generic resources pilot collector that ships ArgoCD + Applications, Clusters, and Repositories to Datadog as generic + resources. Disabled by default. + value: + type: boolean + example: false + - name: generic_resources_endpoint + description: | + Base URL of the ArgoCD REST API (for example, ``https://argocd.example.com``). + Required when ``collect_genresources`` is set to ``true``. + value: + display_default: null + example: https:// + type: string + - name: generic_resources_auth_token + description: | + Raw bearer token used to authenticate against the ArgoCD REST API. + When set, the collector adds ``Authorization: Bearer `` to + each REST request. Leave unset to inherit the request authentication + configured on the instance (for example, the structured ``auth_token`` + config object handled by the HTTP wrapper). + secret: true + value: + display_default: null + example: + type: string + - name: genresources_ttl_seconds + description: | + Time-to-live in seconds applied to every emitted resource. + Resources expire ``ttl_seconds`` after the last observation. + Minimum of 1. + value: + type: integer + example: 21600 + minimum: 1 + - name: max_resources_per_cycle + description: | + Maximum number of items emitted per resource type per check cycle. + When an ArgoCD API endpoint returns more than this, the excess is + dropped and a warning is logged. Applied independently to + Applications, Clusters, and Repositories. + value: + type: integer + example: 10000 + minimum: 1 + - name: extra_redaction_paths + description: | + Additional dotted JSON paths appended to the baseline redaction + deny-list of every collected resource type. ``[*]`` denotes array + iteration. A path that does not match any field for a given type + is silently skipped. This list is additive; it cannot remove paths + from the baseline. + value: + type: array + items: + type: string + example: [] - template: instances/openmetrics overrides: openmetrics_endpoint.required: false diff --git a/argocd/datadog_checks/argocd/check.py b/argocd/datadog_checks/argocd/check.py index 2a447744de798..653895f3a4606 100644 --- a/argocd/datadog_checks/argocd/check.py +++ b/argocd/datadog_checks/argocd/check.py @@ -15,6 +15,7 @@ NOTIFICATIONS_CONTROLLER_METRICS, REPO_SERVER_METRICS, ) +from .resources import ArgocdResourceCollector ( API_SERVER_NAMESPACE, @@ -40,6 +41,12 @@ def __init__(self, name, init_config, instances): super(ArgocdCheck, self).__init__(name, init_config, instances) self.check_initializations.appendleft(self.parse_config) self.check_initializations.append(self.configure_additional_transformers) + self._resource_collector = ArgocdResourceCollector(self) + + def check(self, instance): + if self.instance.get("collect_genresources"): + self._resource_collector.collect() + super().check(instance) def parse_config(self): endpoint_configs = [ @@ -97,7 +104,7 @@ def argocd_cluster_connection_status_transformer(_metric, sample_data, _runtime_ return argocd_cluster_connection_status_transformer def configure_additional_transformers(self): - endpoints = [key for key in self.instance.keys() if "_endpoint" in key] + endpoints = [key for key in self.instance.keys() if "_endpoint" in key and self.instance[key] in self.scrapers] for endpoint in endpoints: if endpoint == "app_controller_endpoint": self.scrapers[self.instance[endpoint]].metric_transformer.add_custom_transformer( diff --git a/argocd/datadog_checks/argocd/config_models/__init__.py b/argocd/datadog_checks/argocd/config_models/__init__.py index 70857774b0017..5c2bf5c9f46d4 100644 --- a/argocd/datadog_checks/argocd/config_models/__init__.py +++ b/argocd/datadog_checks/argocd/config_models/__init__.py @@ -1,7 +1,3 @@ -# (C) Datadog, Inc. 2022-present -# All rights reserved -# Licensed under a 3-clause BSD style license (see LICENSE) - # This file is autogenerated. # To change this file you should edit assets/configuration/spec.yaml and then run the following commands: # ddev -x validate config -s diff --git a/argocd/datadog_checks/argocd/config_models/defaults.py b/argocd/datadog_checks/argocd/config_models/defaults.py index d7fc7782360ba..fe739106e160f 100644 --- a/argocd/datadog_checks/argocd/config_models/defaults.py +++ b/argocd/datadog_checks/argocd/config_models/defaults.py @@ -1,7 +1,3 @@ -# (C) Datadog, Inc. 2022-present -# All rights reserved -# Licensed under a 3-clause BSD style license (see LICENSE) - # This file is autogenerated. # To change this file you should edit assets/configuration/spec.yaml and then run the following commands: # ddev -x validate config -s @@ -36,6 +32,10 @@ def instance_collect_counters_with_distributions(): return False +def instance_collect_genresources(): + return False + + def instance_collect_histogram_buckets(): return True @@ -56,6 +56,10 @@ def instance_enable_legacy_tags_normalization(): return True +def instance_genresources_ttl_seconds(): + return 21600 + + def instance_histogram_buckets_as_distributions(): return False @@ -80,6 +84,10 @@ def instance_log_requests(): return False +def instance_max_resources_per_cycle(): + return 10000 + + def instance_min_collection_interval(): return 15 diff --git a/argocd/datadog_checks/argocd/config_models/instance.py b/argocd/datadog_checks/argocd/config_models/instance.py index 82de77384f133..24eb5cc60c407 100644 --- a/argocd/datadog_checks/argocd/config_models/instance.py +++ b/argocd/datadog_checks/argocd/config_models/instance.py @@ -1,7 +1,3 @@ -# (C) Datadog, Inc. 2022-present -# All rights reserved -# Licensed under a 3-clause BSD style license (see LICENSE) - # This file is autogenerated. # To change this file you should edit assets/configuration/spec.yaml and then run the following commands: # ddev -x validate config -s @@ -101,6 +97,7 @@ class InstanceConfig(BaseModel): cache_metric_wildcards: Optional[bool] = None cache_shared_labels: Optional[bool] = None collect_counters_with_distributions: Optional[bool] = None + collect_genresources: Optional[bool] = None collect_histogram_buckets: Optional[bool] = None commit_server_endpoint: Optional[str] = None connect_timeout: Optional[float] = None @@ -113,6 +110,10 @@ class InstanceConfig(BaseModel): exclude_metrics_by_labels: Optional[MappingProxyType[str, Union[bool, tuple[str, ...]]]] = None extra_headers: Optional[MappingProxyType[str, Any]] = None extra_metrics: Optional[tuple[Union[str, MappingProxyType[str, Union[str, ExtraMetrics]]], ...]] = None + extra_redaction_paths: Optional[tuple[str, ...]] = None + generic_resources_auth_token: Optional[str] = None + generic_resources_endpoint: Optional[str] = None + genresources_ttl_seconds: Optional[int] = Field(None, ge=1) headers: Optional[MappingProxyType[str, Any]] = None histogram_buckets_as_distributions: Optional[bool] = None hostname_format: Optional[str] = None @@ -128,6 +129,7 @@ class InstanceConfig(BaseModel): kerberos_keytab: Optional[str] = None kerberos_principal: Optional[str] = None log_requests: Optional[bool] = None + max_resources_per_cycle: Optional[int] = Field(None, ge=1) metric_patterns: Optional[MetricPatterns] = None metrics: Optional[tuple[Union[str, MappingProxyType[str, Union[str, Metrics]]], ...]] = None min_collection_interval: Optional[float] = None diff --git a/argocd/datadog_checks/argocd/config_models/shared.py b/argocd/datadog_checks/argocd/config_models/shared.py index 906da89039040..da933d6d8ab3f 100644 --- a/argocd/datadog_checks/argocd/config_models/shared.py +++ b/argocd/datadog_checks/argocd/config_models/shared.py @@ -1,7 +1,3 @@ -# (C) Datadog, Inc. 2022-present -# All rights reserved -# Licensed under a 3-clause BSD style license (see LICENSE) - # This file is autogenerated. # To change this file you should edit assets/configuration/spec.yaml and then run the following commands: # ddev -x validate config -s diff --git a/argocd/datadog_checks/argocd/data/conf.yaml.example b/argocd/datadog_checks/argocd/data/conf.yaml.example index 5b62b76f1cd2e..211c1f6e2476e 100644 --- a/argocd/datadog_checks/argocd/data/conf.yaml.example +++ b/argocd/datadog_checks/argocd/data/conf.yaml.example @@ -76,6 +76,52 @@ instances: # # commit_server_endpoint: http://argocd-commit-server:8087/metrics + ## @param collect_genresources - boolean - optional - default: false + ## Enable the generic resources pilot collector that ships ArgoCD + ## Applications, Clusters, and Repositories to Datadog as generic + ## resources. Disabled by default. + # + # collect_genresources: false + + ## @param generic_resources_endpoint - string - optional + ## Base URL of the ArgoCD REST API (for example, ``https://argocd.example.com``). + ## Required when ``collect_genresources`` is set to ``true``. + # + # generic_resources_endpoint: https:// + + ## @param generic_resources_auth_token - string - optional + ## Raw bearer token used to authenticate against the ArgoCD REST API. + ## When set, the collector adds ``Authorization: Bearer `` to + ## each REST request. Leave unset to inherit the request authentication + ## configured on the instance (for example, the structured ``auth_token`` + ## config object handled by the HTTP wrapper). + # + # generic_resources_auth_token: + + ## @param genresources_ttl_seconds - integer - optional - default: 21600 + ## Time-to-live in seconds applied to every emitted resource. + ## Resources expire ``ttl_seconds`` after the last observation. + ## Minimum of 1. + # + # genresources_ttl_seconds: 21600 + + ## @param max_resources_per_cycle - integer - optional - default: 10000 + ## Maximum number of items emitted per resource type per check cycle. + ## When an ArgoCD API endpoint returns more than this, the excess is + ## dropped and a warning is logged. Applied independently to + ## Applications, Clusters, and Repositories. + # + # max_resources_per_cycle: 10000 + + ## @param extra_redaction_paths - list of strings - optional + ## Additional dotted JSON paths appended to the baseline redaction + ## deny-list of every collected resource type. ``[*]`` denotes array + ## iteration. A path that does not match any field for a given type + ## is silently skipped. This list is additive; it cannot remove paths + ## from the baseline. + # + # extra_redaction_paths: [] + ## @param raw_metric_prefix - string - optional ## A prefix that is removed from all exposed metric names, if present. ## All configuration options will use the prefix-less name. diff --git a/argocd/datadog_checks/argocd/resources.py b/argocd/datadog_checks/argocd/resources.py new file mode 100644 index 0000000000000..2634fa23a9f17 --- /dev/null +++ b/argocd/datadog_checks/argocd/resources.py @@ -0,0 +1,249 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) + +"""Generic resources pilot collector for ArgoCD.""" + +from __future__ import annotations + +import os +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable +from urllib.parse import urlparse + +try: + import datadog_agent +except ImportError: + datadog_agent = None + +if TYPE_CHECKING: + from .check import ArgocdCheck + + +def _instance_prefix(endpoint: str | None) -> str: + """Build a multi-part prefix that disambiguates resources across clusters and envs. + + Order: kube_cluster_name : env. The argocd hostname is used only as a + last-resort fallback when neither cluster name nor env is available + (e.g. an out-of-k8s agent monitoring a remote argocd without DD_ENV set). + """ + parts: list[str] = [] + if datadog_agent is not None: + cluster = "" + try: + cluster = datadog_agent.get_clustername() or "" + except Exception: + pass + if not cluster: + try: + cluster = datadog_agent.get_config("cluster_name") or "" + except Exception: + pass + if cluster: + parts.append(cluster) + try: + tags = datadog_agent.get_config("tags") or [] + except Exception: + tags = [] + env = next((t.split(":", 1)[1] for t in tags if t.startswith("env:")), "") \ + or os.environ.get("DD_ENV", "") + if env: + parts.append(env) + if not parts and endpoint: + host = urlparse(endpoint).hostname or "" + if host: + parts.append(host) + return ":".join(parts) + +GENRESOURCES_API_UP_METRIC = "argocd.genresources.api.up" + + +@dataclass(frozen=True) +class ResourceTypeSpec: + resource_type: str + api_path: str + paths: tuple[str, ...] + annotation_keys: tuple[str, ...] + key_builder: Callable[[dict], str] + + +APPLICATION_REDACTION_PATHS: tuple[str, ...] = ( + "spec.source.helm.parameters[*].value", + "spec.source.helm.values", + "spec.source.helm.valuesObject", + "spec.sources[*].helm.parameters[*].value", + "spec.sources[*].helm.values", + "spec.sources[*].helm.valuesObject", + "spec.source.directory.jsonnet.tlas[*].value", + "spec.source.directory.jsonnet.extVars[*].value", + "spec.sources[*].directory.jsonnet.tlas[*].value", + "spec.sources[*].directory.jsonnet.extVars[*].value", + "spec.source.plugin.env[*].value", + "spec.source.plugin.parameters[*].string", + "spec.sources[*].plugin.env[*].value", + "spec.sources[*].plugin.parameters[*].string", + "spec.source.kustomize.secretVars", + "spec.sources[*].kustomize.secretVars", +) + +CLUSTER_REDACTION_PATHS: tuple[str, ...] = ( + "config.username", + "config.password", + "config.bearerToken", + "config.tlsClientConfig.keyData", + "config.tlsClientConfig.certData", + "config.tlsClientConfig.caData", + "config.awsAuthConfig", + "config.execProviderConfig", +) + +REPOSITORY_REDACTION_PATHS: tuple[str, ...] = ( + "username", + "password", + "sshPrivateKey", + "tlsClientCertData", + "tlsClientCertKey", + "githubAppPrivateKey", + "gcpServiceAccountKey", +) + +APPLICATION_ANNOTATION_KEYS: tuple[str, ...] = ("*.kubernetes.io/last-applied-configuration",) + + +IN_CLUSTER_API = "https://kubernetes.default.svc" + + +def _application_key(item: dict) -> str: + metadata = item.get("metadata") or {} + spec = item.get("spec") or {} + destination = spec.get("destination") or {} + cluster = destination.get("server") or "" + namespace = destination.get("namespace") or metadata.get("namespace") or "default" + name = metadata.get("name") + if not name: + raise ValueError("argocd_application is missing metadata.name") + parts = [namespace, name] + if cluster and cluster != IN_CLUSTER_API: + parts.insert(0, cluster) + return ":".join(parts) + + +def _cluster_key(item: dict) -> str: + server = item.get("server") + if not server: + raise ValueError("argocd_cluster is missing server") + return server + + +def _repository_key(item: dict) -> str: + repo = item.get("repo") + if not repo: + raise ValueError("argocd_repository is missing repo") + return repo + + +RESOURCE_TYPE_SPECS: tuple[ResourceTypeSpec, ...] = ( + ResourceTypeSpec( + resource_type="argocd_application", + api_path="/api/v1/applications", + paths=APPLICATION_REDACTION_PATHS, + annotation_keys=APPLICATION_ANNOTATION_KEYS, + key_builder=_application_key, + ), + ResourceTypeSpec( + resource_type="argocd_cluster", + api_path="/api/v1/clusters", + paths=CLUSTER_REDACTION_PATHS, + annotation_keys=(), + key_builder=_cluster_key, + ), + ResourceTypeSpec( + resource_type="argocd_repository", + api_path="/api/v1/repositories", + paths=REPOSITORY_REDACTION_PATHS, + annotation_keys=(), + key_builder=_repository_key, + ), +) + + +class ArgocdResourceCollector: + """Fetches ArgoCD Applications, Clusters, and Repositories and ships them as generic resources.""" + + def __init__(self, check: "ArgocdCheck") -> None: + self.check = check + instance = check.instance + self._endpoint: str | None = instance.get("generic_resources_endpoint") + self._ttl_seconds: int = instance.get("genresources_ttl_seconds", 21600) + self._max_resources: int = instance.get("max_resources_per_cycle", 10000) + self._extra_paths: list[str] = list(instance.get("extra_redaction_paths") or []) + self._auth_token: str | None = instance.get("generic_resources_auth_token") + self._instance_prefix: str = _instance_prefix(self._endpoint) + + def collect(self) -> None: + if not self._endpoint: + self.check.log.warning( + "collect_genresources is enabled but generic_resources_endpoint is not set; skipping" + ) + for spec in RESOURCE_TYPE_SPECS: + self.check.gauge(GENRESOURCES_API_UP_METRIC, 0, tags=[f"resource_type:{spec.resource_type}"]) + return + + seen_at = int(time.time()) + expire_at = seen_at + self._ttl_seconds + + for spec in RESOURCE_TYPE_SPECS: + self._collect_type(spec, seen_at=seen_at, expire_at=expire_at) + + def _collect_type(self, spec: ResourceTypeSpec, *, seen_at: int, expire_at: int) -> None: + tags = [f"resource_type:{spec.resource_type}"] + try: + items = self._fetch(spec.api_path) + except Exception as exc: + self.check.log.error("genresources: failed to fetch %s: %s", spec.resource_type, exc) + self.check.gauge(GENRESOURCES_API_UP_METRIC, 0, tags=tags) + return + + self.check.gauge(GENRESOURCES_API_UP_METRIC, 1, tags=tags) + + if len(items) > self._max_resources: + self.check.log.warning( + "genresources: volume cap hit (%d / %d) for type=%s; increase max_resources_per_cycle if expected", + self._max_resources, + len(items), + spec.resource_type, + ) + items = items[: self._max_resources] + + for item in items: + self._emit_item(item, spec, seen_at=seen_at, expire_at=expire_at) + + def _fetch(self, api_path: str) -> list[dict]: + url = self._endpoint.rstrip("/") + api_path + kwargs: dict = {} + if self._auth_token: + kwargs["headers"] = {"Authorization": f"Bearer {self._auth_token}"} + response = self.check.http.get(url, **kwargs) + response.raise_for_status() + payload = response.json() + return list(payload.get("items") or []) + + def _emit_item(self, item: dict, spec: ResourceTypeSpec, *, seen_at: int, expire_at: int) -> None: + try: + key = spec.key_builder(item) + if self._instance_prefix: + key = f"{self._instance_prefix}:{key}" + self.check.submit_generic_resource( + type=spec.resource_type, + key=key, + fields=item, + redact={ + "paths": list(spec.paths) + self._extra_paths, + "annotation_keys": list(spec.annotation_keys), + }, + seen_at=seen_at, + expire_at=expire_at, + ) + except Exception: + self.check.log.warning("genresources: skipping malformed %s", spec.resource_type, exc_info=True) diff --git a/argocd/metadata.csv b/argocd/metadata.csv index d6a921b23ee8e..e733037e3e75c 100644 --- a/argocd/metadata.csv +++ b/argocd/metadata.csv @@ -333,3 +333,4 @@ argocd.repo_server.redis.request.duration.seconds.bucket,count,,second,,The Redi argocd.repo_server.redis.request.duration.seconds.count,count,,second,,The count aggregation of the Redis requests duration histogram,0,argocd,repo_server redis request duration seconds count,, argocd.repo_server.redis.request.duration.seconds.sum,count,,second,,The sum aggregation of the Redis requests duration histogram,0,argocd,repo_server redis request duration seconds sum,, argocd.repo_server.repo.pending.request.total,gauge,,request,,The number of pending requests requiring repository lock,0,argocd,repo_server repo pending request,, +argocd.genresources.api.up,gauge,,,,Reachability of the ArgoCD REST API used by the generic resources collector. Emits 1 per resource_type tag on success and 0 on failure.,0,argocd,genresources api up,, diff --git a/argocd/tests/test_resources.py b/argocd/tests/test_resources.py new file mode 100644 index 0000000000000..f2f17bce02049 --- /dev/null +++ b/argocd/tests/test_resources.py @@ -0,0 +1,166 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) + +# TODO: once the helper PR is released and the ``datadog-checks-base`` pin in +# ``pyproject.toml`` resolves to a version that ships ``submit_generic_resource``, +# drop ``create=True`` on the ``patch.object`` calls below and add at least one +# end-to-end test that lets the real helper run and asserts on the captured +# event-platform payload via +# ``aggregator.get_event_platform_events("genresources", parse_json=False)``. +# That catches ``redact=`` signature drift, redaction-contract regressions, and +# proto serialization failures that the current mocks cannot see. + +from __future__ import annotations + +from unittest.mock import patch + +from datadog_checks.argocd import ArgocdCheck +from datadog_checks.argocd.resources import ( + APPLICATION_REDACTION_PATHS, + CLUSTER_REDACTION_PATHS, + GENRESOURCES_API_UP_METRIC, + REPOSITORY_REDACTION_PATHS, +) +from datadog_checks.dev.http import MockResponse + +ARGOCD_ENDPOINT = "https://argocd.example.com" +APPLICATIONS_URL = f"{ARGOCD_ENDPOINT}/api/v1/applications" +CLUSTERS_URL = f"{ARGOCD_ENDPOINT}/api/v1/clusters" +REPOSITORIES_URL = f"{ARGOCD_ENDPOINT}/api/v1/repositories" + + +def _application(name: str, *, namespace: str = "argocd", cluster: str = "https://kubernetes.default.svc") -> dict: + return { + "metadata": {"name": name, "namespace": namespace}, + "spec": {"destination": {"server": cluster, "namespace": namespace}, "source": {}}, + "status": {"sync": {"status": "Synced"}}, + } + + +def _cluster(server: str, *, name: str = "prod", username: str = "", password: str = "") -> dict: + return { + "server": server, + "name": name, + "config": {"username": username, "password": password}, + } + + +def _repository(repo: str, *, username: str = "", password: str = "", ssh_key: str = "") -> dict: + return {"repo": repo, "username": username, "password": password, "sshPrivateKey": ssh_key, "type": "git"} + + +def _instance(**overrides) -> dict: + instance = { + "app_controller_endpoint": "http://app_controller:8082", + "collect_genresources": True, + "generic_resources_endpoint": ARGOCD_ENDPOINT, + "generic_resources_auth_token": "test-token", + } + instance.update(overrides) + return instance + + +def _items_response(items: list[dict], status_code: int = 200) -> MockResponse: + return MockResponse(json_data={"items": items}, status_code=status_code) + + +def test_collect_emits_applications_clusters_and_repositories(aggregator, mock_http_response_per_endpoint): + mock_http_response_per_endpoint( + { + APPLICATIONS_URL: [_items_response([_application("checkout")])], + CLUSTERS_URL: [_items_response([_cluster("https://cluster-a.example")])], + REPOSITORIES_URL: [_items_response([_repository("https://github.com/team/repo")])], + } + ) + check = ArgocdCheck("argocd", {}, [_instance()]) + + with patch.object(check, "submit_generic_resource", create=True) as submit: + check._resource_collector.collect() + + by_type = {call.kwargs["type"]: call.kwargs for call in submit.call_args_list} + assert by_type["argocd_application"]["key"] == "argocd.example.com:https://kubernetes.default.svc:argocd:checkout" + assert by_type["argocd_cluster"]["key"] == "argocd.example.com:https://cluster-a.example" + assert by_type["argocd_repository"]["key"] == "argocd.example.com:https://github.com/team/repo" + for spec_type, redact_paths in ( + ("argocd_application", APPLICATION_REDACTION_PATHS), + ("argocd_cluster", CLUSTER_REDACTION_PATHS), + ("argocd_repository", REPOSITORY_REDACTION_PATHS), + ): + assert by_type[spec_type]["redact"]["paths"][: len(redact_paths)] == list(redact_paths) + aggregator.assert_metric(GENRESOURCES_API_UP_METRIC, value=1, tags=[f"resource_type:{spec_type}"]) + + +def test_collect_appends_extra_redaction_paths_to_every_type(mock_http_response_per_endpoint): + extra = ["spec.source.helm.fileParameters[*].value"] + mock_http_response_per_endpoint( + { + APPLICATIONS_URL: [_items_response([_application("checkout")])], + CLUSTERS_URL: [_items_response([_cluster("https://cluster-a.example")])], + REPOSITORIES_URL: [_items_response([_repository("https://github.com/team/repo")])], + } + ) + check = ArgocdCheck("argocd", {}, [_instance(extra_redaction_paths=extra)]) + + with patch.object(check, "submit_generic_resource", create=True) as submit: + check._resource_collector.collect() + + for call in submit.call_args_list: + assert call.kwargs["redact"]["paths"][-1] == extra[0] + + +def test_collect_isolates_per_endpoint_failures(aggregator, mock_http_response_per_endpoint): + mock_http_response_per_endpoint( + { + APPLICATIONS_URL: [_items_response([_application("checkout")])], + CLUSTERS_URL: [_items_response([], status_code=403)], + REPOSITORIES_URL: [_items_response([_repository("https://github.com/team/repo")])], + } + ) + check = ArgocdCheck("argocd", {}, [_instance()]) + + with patch.object(check, "submit_generic_resource", create=True) as submit: + check._resource_collector.collect() + + emitted_types = {call.kwargs["type"] for call in submit.call_args_list} + assert emitted_types == {"argocd_application", "argocd_repository"} + aggregator.assert_metric(GENRESOURCES_API_UP_METRIC, value=1, tags=["resource_type:argocd_application"]) + aggregator.assert_metric(GENRESOURCES_API_UP_METRIC, value=0, tags=["resource_type:argocd_cluster"]) + aggregator.assert_metric(GENRESOURCES_API_UP_METRIC, value=1, tags=["resource_type:argocd_repository"]) + + +def test_collect_skips_malformed_items_without_poisoning_cycle(mock_http_response_per_endpoint, caplog): + malformed = {"spec": {}} + mock_http_response_per_endpoint( + { + APPLICATIONS_URL: [_items_response([_application("checkout"), malformed, _application("payments")])], + CLUSTERS_URL: [_items_response([])], + REPOSITORIES_URL: [_items_response([])], + } + ) + check = ArgocdCheck("argocd", {}, [_instance()]) + + with patch.object(check, "submit_generic_resource", create=True) as submit: + check._resource_collector.collect() + + application_emits = [c for c in submit.call_args_list if c.kwargs["type"] == "argocd_application"] + assert len(application_emits) == 2 + assert any("skipping malformed argocd_application" in rec.message for rec in caplog.records) + + +def test_collect_caps_per_type_with_warning(mock_http_response_per_endpoint, caplog): + mock_http_response_per_endpoint( + { + APPLICATIONS_URL: [_items_response([_application(f"app-{i}") for i in range(7)])], + CLUSTERS_URL: [_items_response([])], + REPOSITORIES_URL: [_items_response([])], + } + ) + check = ArgocdCheck("argocd", {}, [_instance(max_resources_per_cycle=3)]) + + with patch.object(check, "submit_generic_resource", create=True) as submit: + check._resource_collector.collect() + + application_emits = [c for c in submit.call_args_list if c.kwargs["type"] == "argocd_application"] + assert len(application_emits) == 3 + assert any("volume cap hit" in rec.message and "argocd_application" in rec.message for rec in caplog.records)