Coverage for gco / eks_capabilities_config.py: 100.00%
119 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 validation for the ``eks_capabilities`` cdk.json block.
3EKS Capabilities are AWS-managed installations of AWS Controllers for
4Kubernetes (ACK) and kro that run in an AWS-owned account and attach to a
5cluster as ``AWS::EKS::Capability`` resources. GCO exposes them as opt-in
6per-type knobs: every type is **off by default**, and each enabled type
7synthesizes one capability IAM role and one capability per selected regional
8cluster. Argo CD is deliberately not offered here: GCO installs and runs it
9in the cluster itself (``helm.argocd``, see ``gco.argocd_config``).
11This module owns the block's shape so the config loader, the regional stack,
12the CLI and the live-validation harness all agree on names and defaults. Like
13``gco.enablement_overrides`` and ``gco.inference_proxy_config`` it lives at
14the package top level and imports nothing from ``aws_cdk``: the ``gco`` CLI
15runs without the CDK toolchain installed, so it must be able to read the
16block without pulling ``gco.config`` (whose package import loads the CDK
17app). It deliberately raises :class:`EksCapabilitiesConfigError` (a
18``ValueError``) rather than the loader's ``ConfigValidationError``; the
19loader re-raises with its own exception type.
20"""
22from __future__ import annotations
24import copy
25import json
26import re
27from collections.abc import Iterable, Mapping
28from typing import Any
30#: cdk.json context key.
31EKS_CAPABILITIES_CONTEXT_KEY = "eks_capabilities"
33#: Run-scoped CDK context key carrying a JSON object deep-merged over the
34#: cdk.json block for one deploy (``cdk deploy --context
35#: eks_capabilities_overrides='{"kro": {"enabled": true}}'``). The
36#: capabilities sibling of ``helm_enabled_overrides`` / ``feature_enabled_overrides``:
37#: the live-validation and example harnesses, whose preflight requires a clean
38#: worktree, enable capabilities for one run this way.
39EKS_CAPABILITIES_OVERRIDES_CONTEXT_KEY = "eks_capabilities_overrides"
41#: Capability types GCO can attach, keyed the way cdk.json spells them.
42EKS_CAPABILITY_TYPES: tuple[str, ...] = ("ack", "kro")
44#: cdk.json spelling -> ``AWS::EKS::Capability`` ``Type`` value.
45CAPABILITY_TYPE_API_NAMES: dict[str, str] = {"ack": "ACK", "kro": "KRO"}
47#: Session name the EKS kro capability sets when it assumes its role. The
48#: capability's Kubernetes user is therefore
49#: ``arn:<partition>:sts::<account>:assumed-role/<role name>/KRO`` — the
50#: subject GCO's tenant RBAC for kro binds (AWS docs, "Security
51#: considerations for EKS Capabilities").
52KRO_SESSION_NAME = "KRO"
54#: A managed IAM policy ARN: an AWS managed policy (``iam::aws:policy/...``)
55#: or a customer managed one in a 12-digit account.
56_POLICY_ARN_RE = re.compile(r"arn:aws[a-z-]*:iam::(aws|\d{12}):policy/[\w+=,.@/-]+")
58#: The full block with every knob at its default. ``regions: []`` means every
59#: regional deployment region; a non-empty list restricts the type to that
60#: subset of clusters.
61EKS_CAPABILITIES_DEFAULTS: dict[str, Any] = {
62 "ack": {
63 "enabled": False,
64 "regions": [],
65 # ACK service controllers to leave out (ACK's disabledServices).
66 "disabled_services": [],
67 "enable_cross_namespace": False,
68 # IAM roles the capability role may assume (ACK IAM Role Selectors,
69 # the documented least-privilege model). Empty grants nothing.
70 "assume_role_arns": [],
71 # Managed IAM policies attached to the capability role itself (the
72 # documented "simple permission setup"). Empty grants nothing: ACK
73 # can call no AWS API until one of these two lists says otherwise.
74 "iam_policy_arns": [],
75 },
76 "kro": {
77 "enabled": False,
78 "regions": [],
79 },
80}
83class EksCapabilitiesConfigError(ValueError):
84 """Raised when the ``eks_capabilities`` block is malformed."""
87def _deep_merge(defaults: Mapping[str, Any], overrides: Mapping[str, Any]) -> dict[str, Any]:
88 merged: dict[str, Any] = copy.deepcopy(dict(defaults))
89 for key, value in overrides.items():
90 if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping):
91 merged[key] = _deep_merge(merged[key], value)
92 else:
93 merged[key] = copy.deepcopy(value)
94 return merged
97def parse_eks_capabilities_overrides(raw: object) -> dict[str, Any]:
98 """Parse the ``eks_capabilities_overrides`` context value into a mapping.
100 Accepts a JSON object string (the only shape ``cdk --context`` can carry)
101 or a mapping (cdk.json-style); ``None`` and ``""`` mean no overrides. The
102 result is validated later as part of the merged block, so this only
103 checks the shape.
104 """
105 if raw is None:
106 return {}
107 if isinstance(raw, Mapping):
108 return copy.deepcopy(dict(raw))
109 if isinstance(raw, str):
110 if not raw.strip():
111 return {}
112 try:
113 parsed = json.loads(raw)
114 except json.JSONDecodeError as exc:
115 raise EksCapabilitiesConfigError(
116 f"{EKS_CAPABILITIES_OVERRIDES_CONTEXT_KEY} must be a JSON object: {exc}"
117 ) from exc
118 if not isinstance(parsed, Mapping):
119 raise EksCapabilitiesConfigError(
120 f"{EKS_CAPABILITIES_OVERRIDES_CONTEXT_KEY} must be a JSON object, got "
121 f"{type(parsed).__name__}"
122 )
123 return dict(parsed)
124 raise EksCapabilitiesConfigError(
125 f"{EKS_CAPABILITIES_OVERRIDES_CONTEXT_KEY} must be a JSON object string or an object, "
126 f"got {type(raw).__name__}"
127 )
130def merge_eks_capabilities_overrides(raw: object, overrides: Mapping[str, Any]) -> object:
131 """Deep-merge run-scoped ``overrides`` over the raw cdk.json block.
133 Returns ``raw`` untouched when there is nothing to merge (so an absent
134 block stays absent), the overrides alone when the block is absent, and
135 the merged mapping otherwise. A non-mapping block is returned as-is so
136 validation reports it by name.
137 """
138 if not overrides:
139 return raw
140 if raw is None:
141 return copy.deepcopy(dict(overrides))
142 if not isinstance(raw, Mapping):
143 return raw
144 return _deep_merge(raw, overrides)
147def normalize_eks_capabilities_config(raw: object) -> dict[str, Any]:
148 """Return the block with defaults filled in (does not validate).
150 ``None`` and ``{}`` both mean "everything off". Unknown keys are kept so
151 :func:`validate_eks_capabilities_config` can reject them by name.
152 """
153 if raw is None:
154 return copy.deepcopy(EKS_CAPABILITIES_DEFAULTS)
155 if not isinstance(raw, Mapping):
156 raise EksCapabilitiesConfigError(
157 f"{EKS_CAPABILITIES_CONTEXT_KEY} must be an object, got {type(raw).__name__}"
158 )
159 return _deep_merge(EKS_CAPABILITIES_DEFAULTS, raw)
162def _require_bool(value: object, path: str) -> None:
163 if type(value) is not bool:
164 raise EksCapabilitiesConfigError(f"{path} must be a boolean, got {value!r}")
167def _require_string_list(value: object, path: str) -> list[str]:
168 if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
169 raise EksCapabilitiesConfigError(f"{path} must be a list of strings, got {value!r}")
170 if any(not item.strip() for item in value):
171 raise EksCapabilitiesConfigError(f"{path} must not contain empty strings")
172 if len(set(value)) != len(value):
173 raise EksCapabilitiesConfigError(f"{path} lists a value twice")
174 return value
177def _require_arn(value: object, path: str, *, prefix: str = "arn:") -> None:
178 if not isinstance(value, str) or not value.startswith(prefix):
179 raise EksCapabilitiesConfigError(
180 f"{path} must be an ARN starting with {prefix!r}, got {value!r}"
181 )
184def _reject_unknown_keys(block: Mapping[str, Any], allowed: Iterable[str], path: str) -> None:
185 unknown = sorted(str(key) for key in block if key not in set(allowed))
186 if unknown:
187 raise EksCapabilitiesConfigError(
188 f"{path} contains unknown key(s): {', '.join(unknown)}; "
189 f"allowed keys: {', '.join(sorted(allowed))}"
190 )
193def _validate_regions(value: object, path: str, deployment_regions: Iterable[str] | None) -> None:
194 regions = _require_string_list(value, path)
195 if deployment_regions is None:
196 return
197 unknown = sorted(set(regions) - set(deployment_regions))
198 if unknown:
199 raise EksCapabilitiesConfigError(
200 f"{path} names region(s) that are not regional deployment regions: {', '.join(unknown)}"
201 )
204def validate_eks_capabilities_config(
205 raw: object,
206 deployment_regions: Iterable[str] | None = None,
207) -> dict[str, Any]:
208 """Validate the raw cdk.json block and return it normalized.
210 ``deployment_regions`` (the regional deployment regions) lets the
211 per-type ``regions`` subsets be checked; pass ``None`` to skip that check
212 (e.g. when the caller has no region list yet).
213 """
214 if raw is None:
215 return copy.deepcopy(EKS_CAPABILITIES_DEFAULTS)
216 if not isinstance(raw, Mapping):
217 raise EksCapabilitiesConfigError(
218 f"{EKS_CAPABILITIES_CONTEXT_KEY} must be an object, got {type(raw).__name__}"
219 )
220 _reject_unknown_keys(raw, EKS_CAPABILITY_TYPES, EKS_CAPABILITIES_CONTEXT_KEY)
221 for type_name in EKS_CAPABILITY_TYPES:
222 block = raw.get(type_name)
223 if block is not None and not isinstance(block, Mapping):
224 raise EksCapabilitiesConfigError(
225 f"{EKS_CAPABILITIES_CONTEXT_KEY}.{type_name} must be an object, got {block!r}"
226 )
227 if isinstance(block, Mapping):
228 _reject_unknown_keys(
229 block,
230 EKS_CAPABILITIES_DEFAULTS[type_name].keys(),
231 f"{EKS_CAPABILITIES_CONTEXT_KEY}.{type_name}",
232 )
233 config = _deep_merge(EKS_CAPABILITIES_DEFAULTS, raw)
234 regions_list = list(deployment_regions) if deployment_regions is not None else None
236 for type_name in EKS_CAPABILITY_TYPES:
237 block = config[type_name]
238 path = f"{EKS_CAPABILITIES_CONTEXT_KEY}.{type_name}"
239 _require_bool(block["enabled"], f"{path}.enabled")
240 _validate_regions(block["regions"], f"{path}.regions", regions_list)
242 ack = config["ack"]
243 _require_string_list(ack["disabled_services"], "eks_capabilities.ack.disabled_services")
244 _require_bool(ack["enable_cross_namespace"], "eks_capabilities.ack.enable_cross_namespace")
245 for index, arn in enumerate(
246 _require_string_list(ack["assume_role_arns"], "eks_capabilities.ack.assume_role_arns")
247 ):
248 _require_arn(arn, f"eks_capabilities.ack.assume_role_arns[{index}]")
249 for index, arn in enumerate(
250 _require_string_list(ack["iam_policy_arns"], "eks_capabilities.ack.iam_policy_arns")
251 ):
252 if not _POLICY_ARN_RE.fullmatch(arn):
253 raise EksCapabilitiesConfigError(
254 f"eks_capabilities.ack.iam_policy_arns[{index}] must be a managed IAM policy "
255 f"ARN (arn:<partition>:iam::aws:policy/... or arn:<partition>:iam::"
256 f"<account>:policy/...), got {arn!r}"
257 )
259 return config
262def capability_enabled_in_region(config: Mapping[str, Any], type_name: str, region: str) -> bool:
263 """True when ``type_name`` is on and applies to ``region`` (empty regions = all)."""
264 block = config.get(type_name)
265 if not isinstance(block, Mapping) or block.get("enabled") is not True:
266 return False
267 regions = block.get("regions") or []
268 return not regions or region in regions
271def enabled_capability_types(config: Mapping[str, Any], region: str) -> list[str]:
272 """The cdk.json type names enabled for ``region``, in canonical order."""
273 return [
274 name for name in EKS_CAPABILITY_TYPES if capability_enabled_in_region(config, name, region)
275 ]
278def kro_kubernetes_username(*, partition: str, account: str, role_name: str) -> str:
279 """The Kubernetes user the EKS kro capability acts as.
281 Every argument may be a CloudFormation token (the stack passes its
282 partition, account and the generated role name); they are only ever
283 concatenated.
284 """
285 return f"arn:{partition}:sts::{account}:assumed-role/{role_name}/{KRO_SESSION_NAME}"
288#: kubectl-applier tokens rendered for the capability manifests.
289#: ``07-kro-tenant-access.yaml`` is gated on the kro user name.
290EKS_CAPABILITIES_MANIFEST_TOKENS: tuple[str, ...] = ("{{KRO_CAPABILITY_USERNAME}}",)
293def compute_eks_capabilities_replacements(*, kro_username: str | None) -> dict[str, str]:
294 """Build the kubectl-applier replacements for the capability manifests.
296 Shared by the regional stack (deploy) and the tests so both render
297 ``07-kro-tenant-access.yaml`` identically. The file resolves only when
298 ``kro_username`` is given, i.e. the kro capability is enabled for this
299 region. An absent key is the off switch: the applier gates the manifest
300 out of the plan while its token stays unresolved and prunes the inventory
301 registered for that token, exactly like the FSx, Valkey and observability
302 gates.
303 """
304 if kro_username is None:
305 return {}
306 return {"{{KRO_CAPABILITY_USERNAME}}": kro_username}