Coverage for gco / argocd_config.py: 100.00%
134 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""Schema, defaults and rendering for the self-managed Argo CD (``helm.argocd``).
3GCO installs Argo CD into each regional cluster from the upstream ``argo-cd``
4Helm chart when ``cdk.json`` ``helm.argocd.enabled`` is true (off by default;
5``helm_enabled_overrides=argocd`` forces it on for one deploy). The chart runs
6in namespaced mode: its controllers get no ClusterRoles, the in-cluster
7destination is registered for the two tenant namespaces only, and the
8post-Helm manifests ``post-helm-argocd-access.yaml`` /
9``post-helm-argocd-gitops.yaml`` add the tenant RBAC, the fenced
10``AppProject`` and — when ``gitops.repo_url`` is set — one root
11``Application`` pointing Argo CD at an operator repository path. The
12``repo_server`` sub-block sizes the manifest-generation tier and can hand its
13replica count to a CPU HorizontalPodAutoscaler (:func:`argocd_chart_values`).
15This module owns the block's shape so the config loader, the regional stack,
16the ``gco gitops`` CLI and the kind CI job agree on names and defaults. Like
17``gco.eks_capabilities_config`` it imports nothing from ``aws_cdk``: the CLI
18runs without the CDK toolchain installed. It raises :class:`ArgoCdConfigError`
19(a ``ValueError``); the loader re-raises with its own exception type.
20"""
22from __future__ import annotations
24import copy
25import fnmatch
26import json
27from collections.abc import Mapping
28from typing import Any
30#: ``cdk.json`` ``helm`` block key (and ``helm_enabled_overrides`` name).
31ARGOCD_HELM_KEY = "argocd"
33#: charts.yaml entry (the Helm release name, so resources are ``argocd-*``).
34ARGOCD_CHART_NAME = "argocd"
36#: Namespace the chart installs into; the fenced AppProject and the root
37#: Application live here too.
38ARGOCD_NAMESPACE = "argocd"
40#: Fixed object names so the applier's prune inventory, the CLI and the kind
41#: CI job can name them exactly.
42GITOPS_PROJECT_NAME = "gco-tenants"
43GITOPS_ROOT_APPLICATION_NAME = "gco-gitops-root"
45#: Namespaces Argo CD may deploy into. These are the two tenant namespaces GCO
46#: grants Argo CD write RBAC in; the platform namespace ``gco-system`` is
47#: deliberately never a destination, so a compromised tenant repository cannot
48#: touch platform resources. The chart's in-cluster cluster Secret lists the
49#: same two namespaces (charts.yaml).
50GITOPS_TENANT_NAMESPACES: tuple[str, ...] = ("gco-jobs", "gco-inference")
52#: Namespace the root Application deploys manifests without a namespace into.
53GITOPS_DEFAULT_NAMESPACE = "gco-jobs"
55GITOPS_SYNC_POLICIES: tuple[str, ...] = ("manual", "automated")
57#: Placeholders the GitOps ``path`` may carry; substituted per cluster so one
58#: repository can hold a per-cluster overlay directory.
59GITOPS_PATH_PLACEHOLDERS: tuple[str, ...] = ("{region}", "{cluster_name}")
61#: The in-cluster Service the UI is served from, its port (``server.insecure``
62#: makes it plain HTTP behind the kubectl port-forward) and the Secret the
63#: server writes the generated ``admin`` password into on first start.
64ARGOCD_SERVER_SERVICE = "argocd-server"
65ARGOCD_SERVER_PORT = 80
66ARGOCD_ADMIN_USERNAME = "admin"
67ARGOCD_INITIAL_ADMIN_SECRET = "argocd-initial-admin-secret" # nosec B105 # Kubernetes Secret name, not its contents
69#: The repo server the chart renders for release ``argocd``: the Deployment
70#: (and, with autoscaling on, the HorizontalPodAutoscaler of the same name)
71#: and its one container, which the HPA's CPU metric is scoped to.
72ARGOCD_REPO_SERVER_DEPLOYMENT = "argocd-repo-server"
73ARGOCD_REPO_SERVER_CONTAINER = "repo-server"
75#: Inclusive bounds for the repo-server scaling integers (the manifest
76#: processor and inference proxy use the same ranges).
77REPO_SERVER_REPLICAS_RANGE: tuple[int, int] = (1, 50)
78REPO_SERVER_MAX_REPLICAS_RANGE: tuple[int, int] = (1, 100)
79REPO_SERVER_CPU_TARGET_RANGE: tuple[int, int] = (1, 100)
81#: HPA ``behavior`` for the repo server. A push renders every affected
82#: Application at once and then goes quiet, so scale out only on a minute of
83#: sustained load (at most two pods a minute) and scale in one pod every two
84#: minutes after five quiet ones; a single burst of renders does not flap the
85#: tier. The manifest-processor HPA damps its scaling the same way.
86REPO_SERVER_HPA_BEHAVIOR: dict[str, Any] = {
87 "scaleUp": {
88 "stabilizationWindowSeconds": 60,
89 "selectPolicy": "Max",
90 "policies": [{"type": "Pods", "value": 2, "periodSeconds": 60}],
91 },
92 "scaleDown": {
93 "stabilizationWindowSeconds": 300,
94 "selectPolicy": "Min",
95 "policies": [{"type": "Pods", "value": 1, "periodSeconds": 120}],
96 },
97}
99#: The block with every knob at its default.
100ARGOCD_DEFAULTS: dict[str, Any] = {
101 "enabled": False,
102 # Repositories Applications in the gco-tenants project may read from
103 # (Argo CD sourceRepos; glob patterns allowed). "*" admits any repository:
104 # the fence is on destinations and kinds, and only cluster administrators
105 # can create Applications in the argocd namespace.
106 "source_repos": ["*"],
107 "gitops": {
108 # "" leaves the root Application out; set a Git URL to hand the
109 # cluster to a repository path.
110 "repo_url": "",
111 "revision": "HEAD",
112 "path": ".",
113 "sync_policy": "manual",
114 },
115 # The manifest-generation tier. The application controller asks the repo
116 # server to render every Application on each refresh and opens a new gRPC
117 # connection per request, so extra replicas share that work. replicas is
118 # the fixed size and, with autoscaling on, the HPA floor; the HPA then
119 # owns the count up to max_replicas at the CPU target (percent of the
120 # repo-server container's CPU request in charts.yaml).
121 "repo_server": {
122 "replicas": 1,
123 "autoscaling": {
124 "enabled": False,
125 "max_replicas": 5,
126 "cpu_target_utilization_percentage": 70,
127 },
128 },
129}
132class ArgoCdConfigError(ValueError):
133 """Raised when the ``helm.argocd`` block is malformed."""
136def _deep_merge(defaults: Mapping[str, Any], overrides: Mapping[str, Any]) -> dict[str, Any]:
137 merged: dict[str, Any] = copy.deepcopy(dict(defaults))
138 for key, value in overrides.items():
139 if value is None and isinstance(merged.get(key), Mapping):
140 # JSON null for a nested block reads as "absent": keep its defaults.
141 continue
142 if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping):
143 merged[key] = _deep_merge(merged[key], value)
144 else:
145 merged[key] = copy.deepcopy(value)
146 return merged
149def _reject_unknown_keys(block: Mapping[str, Any], allowed: Any, path: str) -> None:
150 unknown = sorted(str(key) for key in block if key not in set(allowed))
151 if unknown:
152 raise ArgoCdConfigError(
153 f"{path} contains unknown key(s): {', '.join(unknown)}; "
154 f"allowed keys: {', '.join(sorted(allowed))}"
155 )
158def _require_object(raw: Mapping[str, Any], key: str, path: str) -> Mapping[str, Any] | None:
159 """``raw[key]`` when it is an object, ``None`` when absent or null; raise otherwise."""
160 value = raw.get(key)
161 if value is not None and not isinstance(value, Mapping):
162 raise ArgoCdConfigError(f"{path} must be an object, got {value!r}")
163 return value
166def _require_int(value: object, bounds: tuple[int, int], path: str) -> int:
167 """An exact integer (``bool`` rejected) inside the inclusive ``bounds``."""
168 low, high = bounds
169 if type(value) is not int or not low <= value <= high:
170 raise ArgoCdConfigError(
171 f"{path} must be an integer between {low} and {high}, got {value!r}"
172 )
173 return value
176def _validate_repo_server(repo_server: Mapping[str, Any]) -> None:
177 """Check the merged ``helm.argocd.repo_server`` block."""
178 replicas = _require_int(
179 repo_server["replicas"], REPO_SERVER_REPLICAS_RANGE, "helm.argocd.repo_server.replicas"
180 )
181 autoscaling = repo_server["autoscaling"]
182 if type(autoscaling["enabled"]) is not bool:
183 raise ArgoCdConfigError(
184 "helm.argocd.repo_server.autoscaling.enabled must be a boolean, "
185 f"got {autoscaling['enabled']!r}"
186 )
187 # Checked when off too, so a typo surfaces before someone turns it on.
188 max_replicas = _require_int(
189 autoscaling["max_replicas"],
190 REPO_SERVER_MAX_REPLICAS_RANGE,
191 "helm.argocd.repo_server.autoscaling.max_replicas",
192 )
193 _require_int(
194 autoscaling["cpu_target_utilization_percentage"],
195 REPO_SERVER_CPU_TARGET_RANGE,
196 "helm.argocd.repo_server.autoscaling.cpu_target_utilization_percentage",
197 )
198 if max_replicas < replicas:
199 raise ArgoCdConfigError(
200 "helm.argocd.repo_server.autoscaling.max_replicas must be at least "
201 f"helm.argocd.repo_server.replicas, got {max_replicas} < {replicas}"
202 )
205def is_git_repository_url(value: str) -> bool:
206 """True for the Git URL shapes Argo CD clones (https://, ssh://, scp-style, ``.git``)."""
207 url = value.strip()
208 return url.startswith(("https://", "ssh://", "git@")) or url.endswith(".git")
211def repository_allowed(repo_url: str, source_repos: list[str]) -> bool:
212 """Whether Argo CD's ``sourceRepos`` glob list admits ``repo_url``."""
213 return any(fnmatch.fnmatchcase(repo_url, pattern) for pattern in source_repos)
216def validate_argocd_config(raw: object) -> dict[str, Any]:
217 """Validate the raw ``helm.argocd`` block and return it with defaults filled.
219 ``None`` means "absent" and yields the defaults (Argo CD off). Unknown keys
220 fail by name, and a ``gitops.repo_url`` the project's ``source_repos``
221 would refuse fails here rather than as a sync error after deploy.
222 """
223 if raw is None:
224 return copy.deepcopy(ARGOCD_DEFAULTS)
225 if not isinstance(raw, Mapping):
226 raise ArgoCdConfigError(f"helm.argocd must be an object, got {type(raw).__name__}")
227 _reject_unknown_keys(raw, ARGOCD_DEFAULTS.keys(), "helm.argocd")
228 gitops_raw = _require_object(raw, "gitops", "helm.argocd.gitops")
229 if gitops_raw is not None:
230 _reject_unknown_keys(gitops_raw, ARGOCD_DEFAULTS["gitops"].keys(), "helm.argocd.gitops")
231 repo_server_raw = _require_object(raw, "repo_server", "helm.argocd.repo_server")
232 if repo_server_raw is not None:
233 _reject_unknown_keys(
234 repo_server_raw, ARGOCD_DEFAULTS["repo_server"].keys(), "helm.argocd.repo_server"
235 )
236 autoscaling_raw = _require_object(
237 repo_server_raw, "autoscaling", "helm.argocd.repo_server.autoscaling"
238 )
239 if autoscaling_raw is not None:
240 _reject_unknown_keys(
241 autoscaling_raw,
242 ARGOCD_DEFAULTS["repo_server"]["autoscaling"].keys(),
243 "helm.argocd.repo_server.autoscaling",
244 )
245 config = _deep_merge(ARGOCD_DEFAULTS, raw)
247 if type(config["enabled"]) is not bool:
248 raise ArgoCdConfigError(f"helm.argocd.enabled must be a boolean, got {config['enabled']!r}")
249 repos = config["source_repos"]
250 if (
251 not isinstance(repos, list)
252 or not repos
253 or not all(isinstance(item, str) and item.strip() for item in repos)
254 ):
255 raise ArgoCdConfigError(
256 f"helm.argocd.source_repos must be a non-empty list of repository URLs or glob "
257 f"patterns, got {repos!r}"
258 )
259 if len(set(repos)) != len(repos):
260 raise ArgoCdConfigError("helm.argocd.source_repos lists a repository twice")
262 gitops = config["gitops"]
263 for key in ("repo_url", "revision", "path", "sync_policy"):
264 if not isinstance(gitops[key], str):
265 raise ArgoCdConfigError(
266 f"helm.argocd.gitops.{key} must be a string, got {gitops[key]!r}"
267 )
268 if gitops["sync_policy"] not in GITOPS_SYNC_POLICIES:
269 raise ArgoCdConfigError(
270 f"helm.argocd.gitops.sync_policy must be one of {', '.join(GITOPS_SYNC_POLICIES)}, "
271 f"got {gitops['sync_policy']!r}"
272 )
273 path = gitops["path"].strip()
274 if path.startswith("/") or ".." in path.split("/"):
275 raise ArgoCdConfigError(
276 "helm.argocd.gitops.path must be a directory relative to the repository root "
277 f"(no leading / and no ..), got {gitops['path']!r}"
278 )
279 repo_url = gitops["repo_url"].strip()
280 if repo_url:
281 if not is_git_repository_url(repo_url):
282 raise ArgoCdConfigError(
283 "helm.argocd.gitops.repo_url must be a Git repository URL (https://, ssh:// "
284 f"or git@), got {gitops['repo_url']!r}"
285 )
286 if not gitops["revision"].strip():
287 raise ArgoCdConfigError(
288 "helm.argocd.gitops.revision must be a non-empty branch, tag or SHA"
289 )
290 if not repository_allowed(repo_url, repos):
291 raise ArgoCdConfigError(
292 f"helm.argocd.gitops.repo_url {repo_url!r} is not admitted by "
293 f"helm.argocd.source_repos {repos}; add it (or a matching pattern)"
294 )
295 _validate_repo_server(config["repo_server"])
296 return config
299def gitops_enabled(config: Mapping[str, Any]) -> bool:
300 """True when the block asks for the root Application (a repository is set)."""
301 gitops = config.get("gitops")
302 return isinstance(gitops, Mapping) and bool(str(gitops.get("repo_url") or "").strip())
305def render_gitops_path(path_template: str, *, region: str, cluster_name: str) -> str:
306 """Substitute the per-cluster placeholders in a GitOps ``path``.
308 Plain ``str.replace`` rather than ``str.format`` so any other brace in the
309 path (a Kustomize overlay dir named ``{prod}`` for instance) is left alone.
310 An empty path means the repository root.
311 """
312 rendered = path_template.replace("{region}", region).replace("{cluster_name}", cluster_name)
313 return rendered.strip() or "."
316def sync_policy_document(policy: str) -> dict[str, Any]:
317 """The Application ``syncPolicy`` for ``manual`` / ``automated``.
319 Automated sync self-heals drift back to Git; prune stays off so a bad
320 commit cannot delete tenant workloads — operators opt into that in-repo.
321 """
322 if policy == "automated":
323 return {"automated": {"selfHeal": True, "prune": False}}
324 return {}
327def argocd_chart_values(config: Mapping[str, Any]) -> dict[str, Any]:
328 """The argo-cd chart values GCO derives from a validated ``helm.argocd`` block.
330 The regional stack hands them to the helm installer (deep-merged over the
331 static ``charts.yaml`` values) and the kind CI job installs with them. They
332 size the repo server: a fixed ``replicas`` count, or — with autoscaling on
333 — the chart's own HorizontalPodAutoscaler between ``replicas`` and
334 ``max_replicas`` (the chart then leaves the Deployment's replica count to
335 it). The metric is CPU only and scoped to the repo-server container: the
336 chart's default memory target never scales back in, because the repo
337 server keeps its rendering memory once it has grown. The target is a
338 percentage of that container's CPU request (``charts.yaml``), and
339 :data:`REPO_SERVER_HPA_BEHAVIOR` damps the scaling.
340 """
341 repo_server = config["repo_server"]
342 autoscaling = repo_server["autoscaling"]
343 replicas = int(repo_server["replicas"])
344 hpa: dict[str, Any] = {"enabled": bool(autoscaling["enabled"])}
345 if hpa["enabled"]:
346 hpa.update(
347 {
348 "minReplicas": replicas,
349 "maxReplicas": int(autoscaling["max_replicas"]),
350 "metrics": [
351 {
352 "type": "ContainerResource",
353 "containerResource": {
354 "name": "cpu",
355 "container": ARGOCD_REPO_SERVER_CONTAINER,
356 "target": {
357 "type": "Utilization",
358 "averageUtilization": int(
359 autoscaling["cpu_target_utilization_percentage"]
360 ),
361 },
362 },
363 }
364 ],
365 "behavior": copy.deepcopy(REPO_SERVER_HPA_BEHAVIOR),
366 }
367 )
368 return {"repoServer": {"replicas": replicas, "autoscaling": hpa}}
371#: kubectl-applier tokens rendered for the Argo CD post-Helm manifests.
372#: ``post-helm-argocd-access.yaml`` is gated on ``{{ARGOCD_ENABLED}}`` (and
373#: carries the structural ``{{ARGOCD_SOURCE_REPOS}}``);
374#: ``post-helm-argocd-gitops.yaml`` is gated on the repository URL.
375ARGOCD_MANIFEST_TOKENS: tuple[str, ...] = (
376 "{{ARGOCD_ENABLED}}",
377 "{{ARGOCD_SOURCE_REPOS}}",
378 "{{ARGOCD_GITOPS_REPO_URL}}",
379 "{{ARGOCD_GITOPS_REVISION}}",
380 "{{ARGOCD_GITOPS_PATH}}",
381 "{{ARGOCD_GITOPS_SYNC_POLICY}}",
382)
385def compute_argocd_replacements(
386 config: Mapping[str, Any],
387 *,
388 enabled: bool,
389 region: str,
390 cluster_name: str,
391) -> dict[str, str]:
392 """Build the kubectl-applier replacements for the Argo CD post-Helm manifests.
394 Shared by the regional stack (deploy) and the kind CI job so both render
395 the two manifests identically. ``enabled`` is the effective chart
396 enablement (cdk.json toggle or a run-scoped ``helm_enabled_overrides``);
397 when it is false nothing is emitted, the applier skips both files and
398 prunes what they applied. The two structural tokens render as single-line
399 JSON (valid YAML flow style) so they are indentation-independent.
400 """
401 if not enabled:
402 return {}
403 replacements = {
404 "{{ARGOCD_ENABLED}}": "true",
405 "{{ARGOCD_SOURCE_REPOS}}": json.dumps([str(repo) for repo in config["source_repos"]]),
406 }
407 if not gitops_enabled(config):
408 return replacements
409 gitops = config["gitops"]
410 replacements.update(
411 {
412 "{{ARGOCD_GITOPS_REPO_URL}}": str(gitops["repo_url"]).strip(),
413 "{{ARGOCD_GITOPS_REVISION}}": str(gitops["revision"]).strip(),
414 "{{ARGOCD_GITOPS_PATH}}": render_gitops_path(
415 str(gitops["path"]), region=region, cluster_name=cluster_name
416 ),
417 "{{ARGOCD_GITOPS_SYNC_POLICY}}": json.dumps(
418 sync_policy_document(str(gitops["sync_policy"]))
419 ),
420 }
421 )
422 return replacements