Skip to content

Commit 53574bf

Browse files
authored
eks-recon: harden node-subnet, PDB, and per-namespace workload facts (#184)
Skill (skills/eks-recon) + DevOps Agent twin (devops-agent/eks-recon): - networking §2a: add node_subnets AZ-resolution fact (EC2 kubelet nodes only; Fargate/hybrid contribute none by design). Handle the 3-segment Fargate providerID; mark node_subnets unconfirmed (never count: 0) when the node list is obtained but EC2 resolution fails. - workloads §8: record PDB status.disruptionsAllowed and spec.unhealthyPodEvictionPolicy; max_unavailable is int|string. - workloads summary/by_namespace: per-namespace hpas/pdbs counts; kube-*-scoped deployments/services are int|null (null, not false-0). - Twin mirrors the parent facts via Coverage enumeration (no MCP, per the DevOps Agent runtime constraints). Website doc copies regenerated via misc/update-pages.sh. Known limitation (#182): the MCP path documents list_k8s_resources for spec/status fields, which that tool summarizes out. CLI path unaffected; fixed separately.
1 parent 58e7e11 commit 53574bf

12 files changed

Lines changed: 662 additions & 42 deletions

File tree

devops-agent/eks-recon/references/compute.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,10 @@ for n in v1.list_node().items:
366366
})
367367
```
368368

369+
> **Node subnets / AZ:** the subnets EC2 nodes run in (aggregated per subnet, with node counts)
370+
> are a networking fact — see `references/networking.md` §2a (`networking.node_subnets`).
371+
> Neither schema carries a per-node subnet/AZ mapping.
372+
369373
**Example output:**
370374
```json
371375
{

devops-agent/eks-recon/references/networking.md

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- [Detection Capabilities](#detection-capabilities)
1111
- [1. VPC Identifiers & Endpoint Access](#1-vpc-identifiers--endpoint-access)
1212
- [2. Subnets & IP Address Availability](#2-subnets--ip-address-availability)
13+
- [2a. Node Subnets & AZ Resolution](#2a-node-subnets--az-resolution)
1314
- [3. CNI Vendor & VPC CNI Configuration](#3-cni-vendor--vpc-cni-configuration)
1415
- [3a. kube-proxy Mode](#3a-kube-proxy-mode)
1516
- [4. Ingress Controllers & Gateway API](#4-ingress-controllers--gateway-api)
@@ -47,8 +48,9 @@ This module reads facts from two sources, both read-only:
4748
If the Kubernetes API is unreachable (access entry absent), report the AWS-API facts and mark
4849
every K8s-dependent sub-fact (`cni.type`/`cni.vpc_cni` env facts, `kube_proxy.*`, `ingress.*`,
4950
`gateway_api.*`, `load_balancers.services`/`target_group_bindings`, `service_mesh.*`,
50-
`dns.coredns` deployment facts / `nodelocal_dns`, `network_policies.*`, `external_dns.*`) as
51-
`unconfirmed` in the report's Coverage section — never as `false`/`count: 0`.
51+
`dns.coredns` deployment facts / `nodelocal_dns`, `network_policies.*`, `external_dns.*`, and
52+
`node_subnets` — its step-1 node list is a K8s-API read) as `unconfirmed` in the report's
53+
Coverage section — never as `false`/`count: 0`.
5254

5355
> **Reference pseudocode note.** Code blocks labeled *reference pseudocode (kubernetes client)*
5456
> below illustrate the resource, fields, and RBAC verbs for each K8s-API read. They are **not
@@ -64,6 +66,7 @@ Network configuration spans multiple layers:
6466
```
6567
1. VPC identifiers & endpoint access -> Which VPC, subnets, SGs; how the API server is reached
6668
2. Subnets & IP availability -> Per-subnet free IPs, secondary CIDRs
69+
2a. Node subnets & AZ -> Subnets nodes actually run in, AZ resolved for all
6770
3. CNI vendor & VPC CNI config -> Pod networking vendor, mode, env vars
6871
4. Ingress & Gateway API -> How external traffic enters the cluster
6972
5. Load balancers -> Provisioned ELBs and target group bindings
@@ -162,6 +165,94 @@ aws ec2 describe-vpcs --vpc-ids <vpc-id> --region <region> \
162165
]
163166
```
164167

168+
### 2a. Node Subnets & AZ Resolution
169+
170+
**Why check this:** The `subnets` list in detection 2 resolves AZ/CIDR only for the subnets in
171+
`cluster.resourcesVpcConfig.subnetIds` (the cluster-registered list). EKS permits node groups to be
172+
deployed into subnets that were not specified at cluster creation (network-reqs, "Subnet
173+
requirements for nodes"), so nodes can run in subnets absent from that list — those node subnets
174+
otherwise carry no AZ, CIDR, or free-IP fact anywhere in recon. This detection resolves them.
175+
176+
**Scope:** EC2 (kubelet) node subnets only. Fargate and Hybrid nodes are not EC2 instances and
177+
by design contribute no `node_subnets` entry. ENIConfig (custom-networking) *pod* subnets are
178+
also out of scope — this is node placement, not pod-ENI placement.
179+
180+
**Via Kubernetes API (step 1)** — read the node list and extract EC2 instance ids from providerIDs:
181+
182+
- **Resource:** `Node`, group/version `v1` (core).
183+
- **Fields to extract:** `spec.providerID` for each node.
184+
- **Filter:** providerIDs use several schemes:
185+
- EC2: `aws:///<az>/<instance-id>`
186+
- Fargate: `aws:///<az>/<profile-or-task-id>/fargate-ip-<a-b-c-d>.<region>.compute.internal`
187+
(3-segment path after `aws:///`, no EC2 instance; observed example in containers-roadmap#1976
188+
— no authoritative doc format exists)
189+
- Hybrid: `eks-hybrid:///<region>/<cluster>/<node-name>` (no EC2 instance)
190+
191+
Keep ONLY `aws:///` providerIDs whose last segment is a real instance id (`^i-`). The
192+
filter is MANDATORY: a Fargate/hybrid/empty id makes the whole `describe-instances
193+
--instance-ids` call abort (Malformed/NotFound) and yields zero node_subnets facts. The
194+
last-segment `^i-` match is unaffected by the Fargate shape (its last segment is a hostname,
195+
not `i-*`); positional parsing that assumed the old 2-segment Fargate scheme would misfire.
196+
- **RBAC verbs:** `get`, `list` on `nodes`.
197+
198+
*Reference pseudocode (kubernetes client), not executable:*
199+
```python
200+
v1 = client.CoreV1Api()
201+
instance_ids = []
202+
for n in v1.list_node().items:
203+
pid = n.spec.provider_id or ""
204+
if pid.startswith("aws:///"):
205+
last = pid.split("/")[-1]
206+
if last.startswith("i-"):
207+
instance_ids.append(last)
208+
```
209+
210+
**Via AWS API** — map each running node instance to its subnet, then resolve AZ/CIDR/free-IP for
211+
every distinct node subnet:
212+
213+
```bash
214+
# step 2: Map each RUNNING node instance to its subnet id; group by SubnetId for node_count.
215+
# Uses --instance-ids (simpler/idiomatic); one fully-purged id aborts the whole batch
216+
# (InvalidInstanceID.NotFound) — see the residual step-2 breaker in Edge Cases.
217+
# The running-state filter drops a node terminated mid-recon (SubnetId null) that would
218+
# otherwise poison the batch.
219+
aws ec2 describe-instances --instance-ids <instance-ids> --region <region> \
220+
--filters Name=instance-state-name,Values=running \
221+
--query 'Reservations[].Instances[].{instance:InstanceId,subnet:SubnetId}'
222+
223+
# step 3: Resolve AZ + CIDR + free-IP for EVERY distinct node subnet id. describe-subnets is not
224+
# limited to the cluster-registered list — it resolves any subnet visible to the caller's
225+
# credentials (owned or RAM-shared).
226+
aws ec2 describe-subnets --subnet-ids <distinct-node-subnet-ids> --region <region> \
227+
--query 'Subnets[].{id:SubnetId,az:AvailabilityZone,az_id:AvailabilityZoneId,cidr:CidrBlock,free:AvailableIpAddressCount,vpc_id:VpcId}'
228+
```
229+
230+
- `node_subnets` = count+list of `{id, az, az_id, cidr, free, vpc_id, node_count, in_cluster_subnet_list}`,
231+
one entry per distinct subnet an EC2 node runs in. Empty list is a valid state (Fargate-only or
232+
zero EC2-node cluster): `count: 0, list: []`. When the node list is unobtainable, OR the node list
233+
IS obtained but the EC2 describe calls fail (describe-instances / describe-subnets: AccessDenied /
234+
unreachable / mid-flow abort), leave the value null AND mark `node_subnets` **unconfirmed** in the
235+
report's Coverage section — NEVER emit `count: 0` (a failed EC2 resolve is not a Fargate-only
236+
cluster; `count: 0` would read as one). The bare `count: 0, list: []` is reserved for the genuine
237+
"no EC2 nodes" case above.
238+
- `az` / `az_id` / `cidr` / `free` = `AvailabilityZone` / `AvailabilityZoneId` / `CidrBlock` /
239+
`AvailableIpAddressCount` from the describe-subnets call (step 3), resolved for every node subnet
240+
including those outside the cluster-registered list. `az_id` is the cross-account-stable zone
241+
identifier (AZ *names* are account-relative — matters for shared / cross-account subnets).
242+
- `vpc_id` = the subnet's `VpcId` (ties an unregistered entry back to its VPC).
243+
- `node_count` = number of running nodes in that subnet (from the describe-instances grouping).
244+
- `in_cluster_subnet_list` = `true` when the subnet id is present in `subnet_ids`
245+
(`cluster.resourcesVpcConfig.subnetIds`), else `false`. A `false` entry is a node subnet absent
246+
from `resourcesVpcConfig.subnetIds` — e.g. a node group launched into an unregistered subnet.
247+
248+
**Example output:**
249+
```json
250+
[
251+
{"id": "subnet-0aaa111", "az": "us-west-2a", "az_id": "usw2-az1", "cidr": "10.0.1.0/24", "free": 210, "vpc_id": "vpc-0abc123", "node_count": 3, "in_cluster_subnet_list": true},
252+
{"id": "subnet-0ddd444", "az": "us-west-2c", "az_id": "usw2-az3", "cidr": "10.4.0.0/20", "free": 4051, "vpc_id": "vpc-0abc123", "node_count": 5, "in_cluster_subnet_list": false}
253+
]
254+
```
255+
165256
### 3. CNI Vendor & VPC CNI Configuration
166257

167258
**Why check this:** The primary CNI vendor determines pod networking behavior. VPC CNI mode
@@ -511,6 +602,23 @@ networking:
511602
free: int # AvailableIpAddressCount
512603
vpc_secondary_cidrs: list # aws ec2 describe-vpcs CidrBlockAssociationSet (beyond primary)
513604

605+
# --- Node subnets (subnets nodes actually run in; AZ resolved for ALL, registered or not) ---
606+
node_subnets: # aws ec2 describe-subnets over EC2 node instance SubnetIds
607+
# EC2 nodes only (Fargate/hybrid contribute none).
608+
# node list unobtainable OR EC2 describe fails => value null + mark unconfirmed
609+
# in Coverage w/ distinguishing reason ("node list unobtainable — K8s API read
610+
# failed" vs "EC2 describe failed: <detail>"), never count:0 (see Access Model + §2a)
611+
count: int
612+
list:
613+
- id: string # SubnetId
614+
az: string # AvailabilityZone (resolved for every node subnet)
615+
az_id: string # AvailabilityZoneId — cross-account-stable zone id
616+
cidr: string # CidrBlock
617+
free: int # AvailableIpAddressCount
618+
vpc_id: string # VpcId (ties an unregistered subnet to its VPC)
619+
node_count: int # running nodes in this subnet
620+
in_cluster_subnet_list: bool # true if id is in subnet_ids (resourcesVpcConfig.subnetIds)
621+
514622
# --- CNI ---
515623
cni:
516624
type: string # aws-vpc-cni | calico | cilium | auto-mode | other (detected, not assumed)
@@ -643,3 +751,28 @@ Non-default settings surface through the aws-node env vars (Security Groups for
643751
Per-subnet free-IP counts are recorded as facts in `subnets.list[].free` (from
644752
`AvailableIpAddressCount`). Secondary VPC CIDRs appear in `vpc_secondary_cidrs`. Report the
645753
numbers; draw no conclusion.
754+
755+
### Node Subnets (§2a) Edge Cases
756+
757+
- **Zero EC2 nodes / Fargate-only cluster:** the step-1 filter yields an empty id list. Do not
758+
call `describe-instances` with no ids (region-wide fallback would return unrelated instances).
759+
Emit `node_subnets: {count: 0, list: []}` — an empty list is a valid fact.
760+
- **Fargate / Hybrid nodes present:** their providerIDs are dropped by the `^i-` filter by design;
761+
they contribute no `node_subnets` entry (not EC2 instances).
762+
- **Node churn mid-recon:** a node terminated between steps 1 and 2 is excluded by the
763+
`instance-state-name=running` filter, so its `null` SubnetId can't break the step-2
764+
group-by-SubnetId or the subnet-id dedup feeding step 3.
765+
(A Node object stale >~1h whose instance is fully purged is the one residual step-2 breaker —
766+
`InvalidInstanceID.NotFound` aborts the describe-instances call; rare, not caught by `--filters`.)
767+
- **Large clusters:** `describe-instances --instance-ids` is unpaginated — the EC2 API guidance
768+
warns unpaginated requests are throttling- and timeout-prone — so chunk the instance ids across
769+
calls and merge, then dedup subnet ids for step 3 and chunk that deduped `--subnet-ids` list into
770+
batches the same way (the same batching approach used for the describe-instances `--instance-ids`
771+
step, since `describe-subnets --subnet-ids` is likewise unpaginated).
772+
- **Node list unobtainable:** value stays null (module-level null rule) AND mark `node_subnets`
773+
unconfirmed in the report's Coverage section (its step-1 node list is a K8s-API read), with
774+
`reason: "node list unobtainable — K8s API read failed"`.
775+
- **Node list obtained but EC2 describe calls fail** (AccessDenied / unreachable / mid-flow abort):
776+
likewise leave the value null and mark `node_subnets` unconfirmed in the Coverage section, with
777+
`reason: "EC2 describe failed: <detail>"` — do NOT emit `count: 0` (a failed EC2 resolve is not a
778+
Fargate-only cluster; `count: 0` reads as one).

devops-agent/eks-recon/references/workloads.md

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -288,25 +288,34 @@ workloads scale automatically and their scaling thresholds.
288288
Enumerate PodDisruptionBudgets (PDBs) across namespaces. A PDB records the minimum
289289
availability guarantee for the pods matched by its selector. Record existence, the
290290
`minAvailable` / `maxUnavailable` value, and the label selector (the workloads it covers).
291+
Also record `status.disruptionsAllowed` — the point-in-time count of voluntary evictions the
292+
Eviction API currently permits for the pods this PDB selects. `0` means it currently rejects
293+
voluntary eviction of covered healthy pods, which can stall a drain of the nodes hosting them
294+
(a PDB selecting zero pods also reports `0`). Record `spec.unhealthyPodEvictionPolicy` too — it
295+
governs whether unhealthy covered pods are evictable at `0`.
291296

292297
**Via Kubernetes API** — list PDBs across all namespaces (VERIFIED live: PDBs expose
293298
MIN AVAILABLE / MAX UNAVAILABLE):
294299

295300
- **Resource:** `PodDisruptionBudget`, group/version `policy/v1`, all namespaces.
296301
- **Fields to extract:** `metadata.namespace`, `metadata.name`, `spec.minAvailable`,
297-
`spec.maxUnavailable`, `spec.selector.matchLabels` (→ `selector`).
302+
`spec.maxUnavailable`, `status.disruptionsAllowed` (→ `disruptions_allowed`),
303+
`spec.unhealthyPodEvictionPolicy` (→ `unhealthy_pod_eviction_policy`),
304+
`spec.selector.matchLabels` (→ `selector`).
298305
- **RBAC verbs:** `get`, `list` on `poddisruptionbudgets.policy`.
299306

300-
Exactly one of `min_available` / `max_unavailable` is set per PDB; the other is `null`.
301-
The `selector` matchLabels identify the covered workloads.
307+
At most one of `min_available` / `max_unavailable` is set per PDB (both may be null for a
308+
selector-only PDB). The `selector` matchLabels identify the covered workloads.
302309

303310
**Example (one PDB):**
304311
```json
305312
{
306313
"namespace": "production",
307314
"name": "api-gateway-pdb",
308-
"min_available": "1",
315+
"min_available": 1,
309316
"max_unavailable": null,
317+
"disruptions_allowed": 2,
318+
"unhealthy_pod_eviction_policy": null,
310319
"selector": {"app": "api-gateway"}
311320
}
312321
```
@@ -441,23 +450,32 @@ detected; never omit a key. Aggregate containers use the `{count, list}` wrapper
441450
```yaml
442451
workloads:
443452
summary:
444-
deployments: int
453+
deployments: int # kube-*-scoped total (§1 excludes kube-*); a true 0 is a valid fact
445454
statefulsets: int
446455
daemonsets: int
447456
cronjobs: int
448457
jobs: int
449-
services: int
458+
services: int # kube-*-scoped total (§5 excludes kube-*); a true 0 is a valid fact
450459
ingresses: int
451460
hpas: int
452461
pdbs: int
453462
namespaces_with_workloads: int
454463

464+
# kube-* scope split across these columns: ONLY deployments and services exclude kube-*
465+
# namespaces (their §1/§5 listings filter kube-* out). statefulsets, ingresses, hpas, and pdbs
466+
# INCLUDE kube-* (their listings apply no namespace filter). A namespace row exists wherever ANY
467+
# column has a count, so rows may be partial. For a scoped-out column emit `null`, NOT 0 (schema
468+
# header rule: null = fact not detected/collected) — e.g. a kube-system row shows `null` for
469+
# deployments and services (column scoped out, not "zero found"; kube-system always runs CoreDNS
470+
# Deployments + kube-dns Services) but real counts for statefulsets/ingresses/hpas/pdbs.
455471
by_namespace:
456472
- namespace: string
457-
deployments: int
473+
deployments: int|null # null for kube-* rows: §1 scopes kube-* out, so not counted here (never 0)
458474
statefulsets: int
459-
services: int
475+
services: int|null # null for kube-* rows: §5 scopes kube-* out, so not counted here (never 0)
460476
ingresses: int
477+
hpas: int # per-namespace HPA count (rolled up from the hpas.list below)
478+
pdbs: int # per-namespace PDB count (rolled up from the pdbs.list below)
461479

462480
deployments:
463481
count: int
@@ -545,8 +563,11 @@ workloads:
545563
list:
546564
- namespace: string
547565
name: string
548-
min_available: string # spec.minAvailable (one of min/max set, other null)
549-
max_unavailable: string # spec.maxUnavailable
566+
min_available: int|string # spec.minAvailable — plain int or "N%" string (K8s IntOrString); at most one is set (both may be null for a selector-only PDB)
567+
max_unavailable: int|string # spec.maxUnavailable — plain int or "N%" string (K8s IntOrString)
568+
disruptions_allowed: int # status.disruptionsAllowed — voluntary evictions the Eviction API currently permits for
569+
# covered pods; 0 rejects eviction of covered healthy pods (also 0 when it selects no pods); null if status unpopulated
570+
unhealthy_pod_eviction_policy: string # spec.unhealthyPodEvictionPolicy — emit verbatim; null when unset (API defaults behavior to IfHealthyBudget)
550571
selector: object # spec.selector.matchLabels — covered workloads
551572

552573
priority_classes:

misc/website/docs/devops-agent/eks-recon/references/compute.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,10 @@ for n in v1.list_node().items:
377377
})
378378
```
379379

380+
> **Node subnets / AZ:** the subnets EC2 nodes run in (aggregated per subnet, with node counts)
381+
> are a networking fact — see `references/networking.md` §2a (`networking.node_subnets`).
382+
> Neither schema carries a per-node subnet/AZ mapping.
383+
380384
**Example output:**
381385
```json
382386
{

0 commit comments

Comments
 (0)