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

1"""Schema, defaults and rendering for the self-managed Argo CD (``helm.argocd``). 

2 

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`). 

14 

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""" 

21 

22from __future__ import annotations 

23 

24import copy 

25import fnmatch 

26import json 

27from collections.abc import Mapping 

28from typing import Any 

29 

30#: ``cdk.json`` ``helm`` block key (and ``helm_enabled_overrides`` name). 

31ARGOCD_HELM_KEY = "argocd" 

32 

33#: charts.yaml entry (the Helm release name, so resources are ``argocd-*``). 

34ARGOCD_CHART_NAME = "argocd" 

35 

36#: Namespace the chart installs into; the fenced AppProject and the root 

37#: Application live here too. 

38ARGOCD_NAMESPACE = "argocd" 

39 

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" 

44 

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") 

51 

52#: Namespace the root Application deploys manifests without a namespace into. 

53GITOPS_DEFAULT_NAMESPACE = "gco-jobs" 

54 

55GITOPS_SYNC_POLICIES: tuple[str, ...] = ("manual", "automated") 

56 

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}") 

60 

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 

68 

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" 

74 

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) 

80 

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} 

98 

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} 

130 

131 

132class ArgoCdConfigError(ValueError): 

133 """Raised when the ``helm.argocd`` block is malformed.""" 

134 

135 

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 

147 

148 

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 ) 

156 

157 

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 

164 

165 

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 

174 

175 

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 ) 

203 

204 

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") 

209 

210 

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) 

214 

215 

216def validate_argocd_config(raw: object) -> dict[str, Any]: 

217 """Validate the raw ``helm.argocd`` block and return it with defaults filled. 

218 

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) 

246 

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") 

261 

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 

297 

298 

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()) 

303 

304 

305def render_gitops_path(path_template: str, *, region: str, cluster_name: str) -> str: 

306 """Substitute the per-cluster placeholders in a GitOps ``path``. 

307 

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 "." 

314 

315 

316def sync_policy_document(policy: str) -> dict[str, Any]: 

317 """The Application ``syncPolicy`` for ``manual`` / ``automated``. 

318 

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 {} 

325 

326 

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. 

329 

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}} 

369 

370 

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) 

383 

384 

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. 

393 

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