-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathkexplain.py
More file actions
executable file
·2283 lines (2085 loc) · 97.9 KB
/
Copy pathkexplain.py
File metadata and controls
executable file
·2283 lines (2085 loc) · 97.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
kexplain: an EXPLAIN plan for Karpenter.
Reconstructs, per node, how Karpenter reached its provisioning decision:
which pending pods triggered it, what constraints were in play, which
instance types were candidates, what CreateFleet actually chose, the node
lifecycle, and any later disruption (consolidation/drift/expiry).
Data sources (harvested into a local store on every run, because they are
ephemeral in the cluster):
* karpenter controller JSON logs (debug level)
* NodeClaim / NodePool / EC2NodeClass / Node objects
* kubernetes events (Nominated, DisruptionBlocked, ...)
Commands:
kexplain sync harvest cluster state into the local store
kexplain nodes list karpenter-managed nodes (live + historical)
kexplain history timeline of provisioning & disruption decisions
kexplain explain <node> full decision trace for a node / nodeclaim
kexplain plan -f pod.yaml before-the-fact: candidate instance types for a pod
kexplain wizard interactive guided investigation (also: bare kexplain)
kexplain doctor prerequisite checks with fix hints
Layout of this file (single file by design; grep for the section markers):
constants tunables and karpenter label keys
utilities shell, time, color, logo, status line
store local jsonl + snapshot persistence (~/.kexplain)
sync harvesting logs/events/objects from the cluster
decision model NodeStory + parsers from logs/events/snapshots
pricing spot price lookup (cached)
commands nodes / history / explain (per-section renderers) / why-not
plan before-the-fact simulation against the EC2 catalog
wizard interactive flows
doctor health checks (run_checks is reused by the wizard)
main argparse wiring, preflight
"""
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone, timedelta
# Alpha. Patch number is auto-bumped to 0.1.<commits-on-main> by the
# version-bump GitHub Action on every push to main. Do not edit by hand.
__version__ = "0.1.26"
STORE_ROOT = os.environ.get("KEXPLAIN_STORE", os.path.expanduser("~/.kexplain"))
KARPENTER_NS = os.environ.get("KARPENTER_NAMESPACE", "kube-system")
# EKS Auto Mode runs the karpenter controller on the AWS-managed control
# plane: there is no pod to read logs from, and the same instance attributes
# are published under eks.amazonaws.com/* instead of karpenter.k8s.aws/*.
# Everything that comes from objects and events still works, so we detect the
# mode once and adapt rather than bail out.
MODE_SELF_HOSTED = "self-hosted"
MODE_AUTO = "auto"
AUTOMODE_LABEL_PREFIX = "eks.amazonaws.com/"
KARPENTER_LABEL_PREFIX = "karpenter.k8s.aws/"
# How much of an instance's raw capacity we assume is schedulable after
# kubelet/system reservation and daemonsets. Rough heuristics; Karpenter
# computes exact overhead from instance type at runtime.
CPU_ALLOCATABLE_RATIO = 0.9
MEM_ALLOCATABLE_RATIO = 0.85
# A "created nodeclaim" is attributed to the provisioning session or
# disruption command that happened at most this many seconds before it.
PROVISION_LINK_WINDOW_S = 60
REPLACEMENT_LINK_WINDOW_S = 15
EC2_CATALOG_TTL_S = 7 * 24 * 3600
KARPENTER_LOG_TAIL = 200
# funnel rendering: bar width in characters, and how small the surviving
# set must be before we list type names / family names under a stage
FUNNEL_BAR_WIDTH = 30
FUNNEL_NAME_SURVIVORS_MAX = 12
FUNNEL_NAME_FAMILIES_MAX = 40
# Requirement keys injected by karpenter itself; not user-meaningful
# when explaining a decision.
INTERNAL_REQ_KEYS = frozenset({
"karpenter.sh/nodepool", "karpenter.k8s.aws/ec2nodeclass",
"eks.amazonaws.com/nodeclass",
"kubernetes.io/os",
})
# Instance attributes Auto Mode publishes under its own prefix but which mean
# exactly what the karpenter.k8s.aws/* key of the same name means. Normalizing
# these lets match_requirement / the funnel / why-not work unchanged; without
# it they hit the unknown-key fallthrough and silently filter nothing.
AUTOMODE_EQUIVALENT_SUFFIXES = frozenset({
"instance-category", "instance-family", "instance-generation",
"instance-cpu", "instance-memory", "instance-size",
"instance-cpu-manufacturer", "instance-network-bandwidth",
"instance-gpu-count", "instance-gpu-name", "instance-gpu-manufacturer",
"instance-gpu-memory", "instance-accelerator-count",
"instance-accelerator-name", "instance-accelerator-manufacturer",
"instance-local-nvme", "instance-hypervisor", "instance-encryption-in-transit-supported",
})
def normalize_req_key(key):
"""Map an Auto Mode instance attribute onto its karpenter.k8s.aws
equivalent. Keys with no equivalent (nodeclass, instance-capability-flex,
nitro-sandbox, ...) are returned unchanged and fall through as before."""
if key.startswith(AUTOMODE_LABEL_PREFIX):
suffix = key[len(AUTOMODE_LABEL_PREFIX):]
if suffix in AUTOMODE_EQUIVALENT_SUFFIXES:
return KARPENTER_LABEL_PREFIX + suffix
return key
# ---------------------------------------------------------------- utilities
def sh(cmd, check=True, timeout=120):
p = subprocess.run(cmd, shell=isinstance(cmd, str), capture_output=True,
text=True, timeout=timeout)
if check and p.returncode != 0:
raise RuntimeError(f"command failed: {cmd}\n{p.stderr.strip()}")
return p.stdout
def kubectl_json(args):
out = sh(f"kubectl {args} -o json")
return json.loads(out)
def parse_ts(s):
if not s:
return None
s = s.replace("Z", "+00:00")
# trim sub-second precision to 6 digits for fromisoformat
m = re.match(r"(.*\.\d{1,6})\d*(\+.*|$)", s)
if m:
s = m.group(1) + (m.group(2) or "+00:00")
try:
dt = datetime.fromisoformat(s)
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
except ValueError:
return None
def fmt_ts(dt):
return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ") if dt else "?"
def fmt_dur(seconds):
if seconds is None:
return "?"
seconds = int(seconds)
if seconds < 120:
return f"{seconds}s"
if seconds < 7200:
return f"{seconds // 60}m{seconds % 60:02d}s"
return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m"
USE_COLOR = sys.stdout.isatty()
def c(code, s):
return f"\033[{code}m{s}\033[0m" if USE_COLOR else str(s)
def bold(s): return c("1", s)
def dim(s): return c("2", s)
def green(s): return c("32", s)
def yellow(s): return c("33", s)
def red(s): return c("31", s)
def cyan(s): return c("36", s)
def magenta(s): return c("35", s)
def print_logo(subtitle=""):
"""Small banner for interactive use. Skipped when piped."""
if not sys.stdout.isatty():
return
art = (
"▌ ▜ ▘\n"
"▙▘█▌▚▘▛▌▐ ▀▌▌▛▌\n"
"▛▖▙▖▞▖▙▌▐▖█▌▌▌▌\n"
" ▌"
)
print(cyan(art))
print(dim(f" an EXPLAIN plan for Karpenter v{__version__} (alpha)"
f"{(' | ' + subtitle) if subtitle else ''}\n"))
def status(msg=None):
"""Transient one-line progress message; each call replaces the previous
one, and status() with no argument clears it. No-op when piped."""
if not sys.stdout.isatty():
return
sys.stdout.write("\r\033[2K")
if msg:
sys.stdout.write(dim(f" {msg}"))
sys.stdout.flush()
# ---------------------------------------------------------------- store
class Store:
"""Local persistence: logs.jsonl (deduped), object snapshots, events."""
def __init__(self, cluster):
self.dir = os.path.join(STORE_ROOT, cluster)
for sub in ("", "nodeclaims", "nodes", "nodepools", "ec2nodeclasses"):
os.makedirs(os.path.join(self.dir, sub), exist_ok=True)
self.log_path = os.path.join(self.dir, "logs.jsonl")
self.events_path = os.path.join(self.dir, "events.jsonl")
# ---- generic jsonl with dedup by content hash
def _load_jsonl(self, path):
rows = []
if os.path.exists(path):
with open(path) as f:
for line in f:
line = line.strip()
if line:
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
pass
return rows
def _append_jsonl(self, path, rows, key_fn):
seen = set()
for r in self._load_jsonl(path):
seen.add(key_fn(r))
added = 0
with open(path, "a") as f:
for r in rows:
k = key_fn(r)
if k not in seen:
seen.add(k)
f.write(json.dumps(r, separators=(",", ":")) + "\n")
added += 1
return added
@staticmethod
def _log_key(r):
return hashlib.sha1(json.dumps(
[r.get("time"), r.get("message"), r.get("NodeClaim"), r.get("Pods"),
r.get("command-id"), r.get("controller")],
sort_keys=True).encode()).hexdigest()
@staticmethod
def _event_key(e):
return f'{e.get("uid")}:{e.get("count")}:{e.get("lastTimestamp")}'
def add_logs(self, rows):
return self._append_jsonl(self.log_path, rows, self._log_key)
def add_events(self, rows):
return self._append_jsonl(self.events_path, rows, self._event_key)
def logs(self):
rows = self._load_jsonl(self.log_path)
rows.sort(key=lambda r: r.get("time") or "")
return rows
def events(self):
return self._load_jsonl(self.events_path)
# ---- object snapshots (latest wins; survive deletion in-cluster)
def snapshot(self, kind_dir, obj):
name = obj["metadata"]["name"]
with open(os.path.join(self.dir, kind_dir, name + ".json"), "w") as f:
json.dump(obj, f)
def objects(self, kind_dir):
out = {}
d = os.path.join(self.dir, kind_dir)
for fn in os.listdir(d):
if fn.endswith(".json"):
with open(os.path.join(d, fn)) as f:
try:
obj = json.load(f)
out[obj["metadata"]["name"]] = obj
except (json.JSONDecodeError, KeyError):
pass
return out
# ---------------------------------------------------------------- sync
def current_cluster():
try:
ctx = sh("kubectl config current-context").strip()
except RuntimeError:
sys.exit("error: no kubectl context. Is your kubeconfig set up?")
# eksctl contexts look like user@cluster.region.eksctl.io
m = re.search(r"@?([\w-]+)\.([\w-]+)\.eksctl\.io", ctx)
if m:
return m.group(1)
m = re.search(r"cluster/([\w-]+)", ctx)
return m.group(1) if m else re.sub(r"[^\w.-]", "_", ctx)
_mode_cache = None
def cluster_mode():
"""MODE_AUTO when EKS Auto Mode owns the karpenter controller, else
MODE_SELF_HOSTED. Detected from the NodeClass CRD group, which is
eks.amazonaws.com on Auto Mode and karpenter.k8s.aws otherwise; falls back
to the managed-by label on NodePools. Cached for the process lifetime."""
global _mode_cache
if _mode_cache is not None:
return _mode_cache
_mode_cache = MODE_SELF_HOSTED
try:
res = sh("kubectl api-resources --no-headers", check=False, timeout=30)
has_auto = "eks.amazonaws.com" in res and re.search(
r"^nodeclasses\s+.*eks\.amazonaws\.com", res, re.M)
has_karpenter = re.search(
r"^ec2nodeclasses\s+.*karpenter\.k8s\.aws", res, re.M)
if has_auto and not has_karpenter:
_mode_cache = MODE_AUTO
elif has_auto and has_karpenter:
# both CRDs exist: let the live NodePools decide
try:
pools = kubectl_json("get nodepools")["items"]
if pools and all((p["metadata"].get("labels") or {})
.get("app.kubernetes.io/managed-by") == "eks"
for p in pools):
_mode_cache = MODE_AUTO
except Exception:
pass
except Exception:
pass
return _mode_cache
def is_auto_mode():
return cluster_mode() == MODE_AUTO
# NodeClaim status conditions / event reasons that stand in for the log
# lifecycle lines on Auto Mode.
EVENT_LIFECYCLE_REASONS = ("Launched", "Registered", "Initialized")
def sync(store, quiet=False):
def note(msg):
if not quiet:
print(dim(f" sync: {msg}"))
auto = is_auto_mode()
# -- karpenter controller logs (current + previous container).
# Auto Mode has no controller pod in the cluster, so skip the attempt
# entirely rather than logging a misleading failure.
if auto:
note("EKS Auto Mode: controller runs on the AWS control plane, "
"no logs to harvest (using objects + events)")
else:
raw = ""
for flag in ("", "--previous"):
try:
raw += sh(f"kubectl logs -n {KARPENTER_NS} "
f"-l app.kubernetes.io/name=karpenter "
f"--all-containers --tail=-1 {flag}", check=False)
except Exception:
pass
rows = []
for line in raw.splitlines():
line = line.strip()
if line.startswith("{"):
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
pass
note(f"{store.add_logs(rows)} new log lines ({len(rows)} fetched)")
# -- events from all namespaces (karpenter events land on pods/nodes/nodeclaims)
try:
evs = kubectl_json("get events -A")["items"]
keep = []
for e in evs:
src = (e.get("source", {}) or {}).get("component", "") or \
e.get("reportingComponent", "")
if "karpenter" in src or e.get("reason") in (
"Nominated", "FailedScheduling", "DisruptionBlocked",
"Unconsolidatable", "DisruptionTerminating", "Evicted",
# Auto Mode publishes the nodeclaim lifecycle as events
# (status-condition transitions) instead of logs.
"Launched", "Registered", "Initialized", "Finalized",
"Disrupted", "Expired", "Drifted", "Empty",
"Underutilized", "SpotInterrupted", "TerminationGracePeriodExpiring"):
keep.append({
"uid": e["metadata"]["uid"],
"reason": e.get("reason"),
"message": e.get("message"),
"count": e.get("count"),
"kind": e.get("involvedObject", {}).get("kind"),
"name": e.get("involvedObject", {}).get("name"),
"namespace": e.get("involvedObject", {}).get("namespace"),
"lastTimestamp": e.get("lastTimestamp") or
e.get("eventTime") or
e.get("firstTimestamp"),
"source": src,
})
note(f"{store.add_events(keep)} new events")
except Exception as ex:
note(f"events failed: {ex}")
# -- object snapshots. Auto Mode's node class CRD is
# nodeclasses.eks.amazonaws.com; self-hosted uses
# ec2nodeclasses.karpenter.k8s.aws. Both land in the same store dir so
# every downstream reader stays mode-agnostic.
nodeclass_kind = "nodeclasses.eks.amazonaws.com" if auto else "ec2nodeclasses"
for kind, kdir in (("nodeclaims", "nodeclaims"), ("nodepools", "nodepools"),
(nodeclass_kind, "ec2nodeclasses")):
try:
for obj in kubectl_json(f"get {kind}")["items"]:
obj.pop("managedFields", None)
store.snapshot(kdir, obj)
except Exception:
pass
try:
for obj in kubectl_json("get nodes")["items"]:
obj["metadata"].pop("managedFields", None)
store.snapshot("nodes", obj)
except Exception:
pass
note("object snapshots updated")
# ---------------------------------------------------------------- decision model
class NodeStory:
"""Everything we know about one nodeclaim's life."""
def __init__(self, name):
self.name = name # nodeclaim name
self.node = None # k8s node name
self.nodepool = None
self.provider_id = None
self.instance_type = None
self.zone = None
self.capacity_type = None
self.allocatable = None
self.requests = None # aggregated requests at creation
self.candidate_types = None # truncated list from logs
self.candidate_count = None
self.trigger_pods = [] # [(ns/pod, ...)]
self.trigger_reasons = [] # [(reason, count)] why the pods didn't fit
self.nominated_pods = []
self.t_created = self.t_launched = self.t_registered = None
self.t_initialized = self.t_deleted = None
self.disruption = None # dict: reason/decision/replacements/ts
self.disruption_blocked = [] # [(ts, message)]
self.replaces = None # dict: this node replaced others via consolidation
self.raw_claim = None
@property
def has_claim_spec(self):
"""True when the store captured the NodeClaim object itself. Without
it we know WHAT launched (from logs) but not the resolved
requirements, so pod-constraint attribution is impossible."""
return bool(self.raw_claim)
def _nodeclaim_name(row):
nc = row.get("NodeClaim")
return nc.get("name") if isinstance(nc, dict) else nc
def _parse_candidate_types(itypes):
"""Parse the created-nodeclaim 'instance-types' field, e.g.
"c3.2xlarge, c3.4xlarge, c3.8xlarge and 595 other(s)" -> (list, count).
The last named type is glued to "and N other(s)"."""
extra = 0
m = re.search(r"\s+and (\d+) other\(s\)", itypes)
if m:
extra = int(m.group(1))
itypes = itypes[:m.start()]
types = [p.strip() for p in itypes.split(",") if p.strip()]
return types, len(types) + extra
def _latest_within(entries, ts, window_s):
"""Most recent entry whose timestamp is at most window_s before ts."""
for entry in reversed(entries):
ets = entry[0]
if ets and ts and 0 <= (ts - ets).total_seconds() <= window_s:
return entry
return None
def _apply_created(s, row, ts, sessions, replace_cmds):
s.t_created = ts
np = row.get("NodePool")
s.nodepool = np.get("name") if isinstance(np, dict) else np
s.requests = row.get("requests")
itypes = row.get("instance-types", "")
if isinstance(itypes, str):
s.candidate_types, s.candidate_count = _parse_candidate_types(itypes)
# attribute this nodeclaim to a provisioning session or to the
# consolidation command it replaces, whichever happened just before
session = _latest_within(sessions, ts, PROVISION_LINK_WINDOW_S)
if session:
s.trigger_pods = [p.strip() for p in session[1].split(",") if p.strip()]
repl = _latest_within(replace_cmds, ts, REPLACEMENT_LINK_WINDOW_S)
if repl:
_, rnames, rreason, rsav = repl
s.replaces = {"nodes": rnames, "reason": rreason, "savings": rsav}
def _apply_launched(s, row, ts):
s.t_launched = ts
s.provider_id = row.get("provider-id")
s.instance_type = row.get("instance-type")
s.zone = row.get("zone")
s.capacity_type = row.get("capacity-type")
s.allocatable = row.get("allocatable")
def _parse_disruption(row, ts, fallback_name):
"""Parse a v1.x "disrupting node(s)" log line. The "command" field looks
like "Empty/<uuid>: delete: nodepools=[default]: [node-a] (savings: $0.27)";
reason is the prefix, savings appears for consolidation decisions.
Returns (disrupted nodeclaim names, disruption dict)."""
cmd = row.get("command", "") or ""
reason = row.get("reason") or (cmd.split("/", 1)[0] if "/" in cmd else None)
m = re.search(r"savings: \$([\d.]+)", cmd)
savings = float(m.group(1)) if m else None
disrupted = row.get("disrupted-nodes") or row.get("nodes") or []
if isinstance(disrupted, dict):
disrupted = [disrupted]
names = []
for d in disrupted:
if isinstance(d, dict):
nc = d.get("NodeClaim")
nm = nc.get("name") if isinstance(nc, dict) else nc
if nm:
names.append(nm)
if fallback_name and not names:
names = [fallback_name]
disruption = {
"ts": ts, "reason": reason, "decision": row.get("decision"),
"replacements": row.get("replacement-node-count", 0),
"pods": row.get("pod-count"),
"savings": savings,
"raw": cmd or row.get("message", ""),
}
return names, disruption
# kube-scheduler taints on nodes that are still booting; not a real reason
# a pod cannot be placed, so we drop them from the "why didn't it fit" list
TRANSIENT_TAINTS = ("unregistered", "not-ready", "uninitialized")
def scheduling_reasons(msg):
"""Extract the human reasons from a FailedScheduling event message, e.g.
"0/3 nodes are available: 1 Insufficient cpu, 2 node(s) didn't match
Pod's node affinity/selector. preemption: ..." -> ["Insufficient cpu",
"didn't match Pod's node affinity/selector"]. Transient boot taints are
dropped; the leading per-reason node count is stripped."""
m = re.search(r"nodes are available:\s*(.*?)(?:\.?\s*preemption:|$)", msg or "")
if not m:
return []
reasons = []
for part in m.group(1).split(","):
p = re.sub(r"^\d+\s+", "", part.strip()).rstrip(".")
if any(t in p for t in TRANSIENT_TAINTS):
continue
if "untolerated taint" in p:
tm = re.search(r"\{([^:} ]+)", p)
p = f"untolerated taint {tm.group(1)}" if tm else p
p = p.replace("node(s) ", "").strip()
if p:
reasons.append(p)
return reasons
def _apply_events(stories, events):
"""Fold karpenter events (nominations, disruption blocks) into stories,
and attribute FailedScheduling reasons to the nodes their pods triggered."""
# map each trigger pod (ns/name) to the story it launched
pod_to_story = {}
for st in stories.values():
for pod in st.trigger_pods:
pod_to_story.setdefault(pod, st)
reason_counts = {} # story name -> {reason: count}
for e in events:
ts = parse_ts(e.get("lastTimestamp"))
reason = e.get("reason")
if reason == "Nominated" and e.get("kind") == "Pod":
m = re.search(r"nodeclaim/([\w-]+)", e.get("message", ""))
if m and m.group(1) in stories:
stories[m.group(1)].nominated_pods.append(
f'{e.get("namespace")}/{e.get("name")}')
elif reason in ("DisruptionBlocked", "Unconsolidatable"):
nm = e.get("name")
if e.get("kind") == "NodeClaim" and nm in stories:
stories[nm].disruption_blocked.append((ts, e.get("message")))
elif reason in EVENT_LIFECYCLE_REASONS and e.get("kind") == "NodeClaim":
# Auto Mode has no controller logs, so the nodeclaim status
# transitions published as events are the only lifecycle source.
# Never overwrite a log-derived timestamp: logs are more precise.
st = stories.get(e.get("name"))
if st and ts:
attr = {"Launched": "t_launched", "Registered": "t_registered",
"Initialized": "t_initialized"}[reason]
if getattr(st, attr) is None:
setattr(st, attr, ts)
elif reason == "FailedScheduling" and e.get("kind") == "Pod":
pod = f'{e.get("namespace")}/{e.get("name")}'
st = pod_to_story.get(pod)
if st:
bucket = reason_counts.setdefault(st.name, {})
for r in scheduling_reasons(e.get("message")):
bucket[r] = bucket.get(r, 0) + 1
for name, bucket in reason_counts.items():
stories[name].trigger_reasons = sorted(
bucket.items(), key=lambda kv: -kv[1])
def _apply_snapshot(s, obj):
"""Fill story gaps from a NodeClaim object snapshot (covers nodeclaims
whose creation predates the harvested logs)."""
s.raw_claim = obj
md, st = obj["metadata"], obj.get("status", {})
labels = md.get("labels", {})
s.nodepool = s.nodepool or labels.get("karpenter.sh/nodepool")
s.instance_type = s.instance_type or labels.get("node.kubernetes.io/instance-type")
s.zone = s.zone or labels.get("topology.kubernetes.io/zone")
s.capacity_type = s.capacity_type or labels.get("karpenter.sh/capacity-type")
s.provider_id = s.provider_id or st.get("providerID")
s.node = s.node or st.get("nodeName")
if not s.t_created:
s.t_created = parse_ts(md.get("creationTimestamp"))
def build_stories(store):
"""Parse harvested logs, events, and snapshots into per-nodeclaim
NodeStory objects, keyed by nodeclaim name."""
stories = {}
def get(name):
if name not in stories:
stories[name] = NodeStory(name)
return stories[name]
sessions = [] # (ts, pods_str, row) per "found provisionable pod(s)"
replace_cmds = [] # (ts, disrupted names, reason, savings) per replace command
for r in store.logs():
msg = r.get("message", "")
ts = parse_ts(r.get("time"))
ncn = _nodeclaim_name(r)
if msg == "found provisionable pod(s)":
sessions.append((ts, r.get("Pods", ""), r))
elif msg == "created nodeclaim" and ncn:
_apply_created(get(ncn), r, ts, sessions, replace_cmds)
elif msg == "launched nodeclaim" and ncn:
_apply_launched(get(ncn), r, ts)
elif msg == "registered nodeclaim" and ncn:
s = get(ncn)
s.t_registered = ts
node = r.get("Node")
s.node = node.get("name") if isinstance(node, dict) else node
elif msg == "initialized nodeclaim" and ncn:
get(ncn).t_initialized = ts
elif msg == "deleted nodeclaim" and ncn:
get(ncn).t_deleted = ts
elif "disrupting node(s)" in msg or "disrupting nodeclaim(s)" in msg:
names, disruption = _parse_disruption(r, ts, ncn)
for nm in names:
get(nm).disruption = disruption
if disruption["replacements"] > 0:
replace_cmds.append((ts, names, disruption["reason"],
disruption["savings"]))
# snapshots first: on Auto Mode there are no logs, so the snapshot is what
# creates the story at all, and _apply_events needs it to already exist
# before it can attach event-derived lifecycle timestamps to it.
for name, obj in store.objects("nodeclaims").items():
_apply_snapshot(get(name), obj)
_apply_events(stories, store.events())
for s in stories.values():
s.nominated_pods = sorted(set(s.nominated_pods))
return stories
def live_nodeclaims():
try:
return {o["metadata"]["name"]: o for o in kubectl_json("get nodeclaims")["items"]}
except Exception:
return {}
def instance_id(provider_id):
return provider_id.rsplit("/", 1)[-1] if provider_id else None
# AWS instance name suffix letters (the part after the generation digit)
# and what each one means. Processor letters are informational; the rest
# mark capability variants. Source: AWS EC2 instance type naming convention.
SUFFIX_MEANINGS = {
"a": "AMD processor",
"g": "AWS Graviton processor",
"i": "Intel processor",
"d": "local NVMe instance storage",
"n": "network and EBS optimized",
"e": "extra storage or memory",
"z": "high CPU frequency",
"b": "block storage (EBS) optimized",
"q": "Qualcomm inference accelerator",
"f": "fractional GPU or flex",
}
PROCESSOR_SUFFIXES = frozenset("agi")
# Family category letters (before the generation digit) per the AWS
# instance-type naming docs. Every resource-specialized family is notable in
# FEATURE ATTRIBUTION (compute, memory, storage, accelerated, HPC, ...): the
# question "did you ask for this profile, or did CreateFleet just pick it?"
# applies equally to all of them. Only the general-purpose baselines
# (m = general purpose, t = burstable) are treated as unremarkable.
CATEGORY_MEANINGS = {
"m": "general purpose", "t": "burstable general purpose",
"c": "compute optimized", "r": "memory optimized",
"x": "high memory", "u": "high memory (u family)",
"z": "high frequency memory optimized", "i": "storage optimized (NVMe)",
"is": "storage optimized", "im": "storage optimized",
"d": "dense HDD storage", "h": "HDD storage",
"p": "GPU accelerated (training-class)", "g": "GPU accelerated",
"gr": "GPU accelerated (memory-heavy)", "dl": "deep learning accelerated",
"inf": "AWS Inferentia ML accelerator", "trn": "AWS Trainium ML accelerator",
"f": "FPGA accelerated", "vt": "video transcoding",
"hpc": "HPC optimized", "mac": "Apple macOS",
}
GENERAL_PURPOSE_CATEGORIES = frozenset({"m", "t"})
NOTABLE_CATEGORIES = frozenset(CATEGORY_MEANINGS) - GENERAL_PURPOSE_CATEGORIES
def instance_features(itype):
"""Decode an instance type name per the AWS naming convention:
category letters, generation digit(s), capability suffix letters, and an
optional -variant (flex, b200, 3tb1, m4pro, ...). Handles the odd
families too: mac-m4, u-6tb1, c7i-flex, p6-b200, gr6f."""
fam = itype.split(".")[0]
base, _, variant = fam.partition("-")
m = re.match(r"([a-z]+?)(\d+)([a-z]*)$", base)
if not m:
# no generation digit at all (e.g. "mac" in mac-m4, "u" in u-3tb1)
return {"family": fam, "category": base, "generation": 0, "suffix": "",
"variant": variant, "capabilities": []}
suffix = m.group(3)
caps = [SUFFIX_MEANINGS[ch] for ch in suffix
if ch not in PROCESSOR_SUFFIXES and ch in SUFFIX_MEANINGS]
return {"family": fam, "category": m.group(1), "generation": int(m.group(2)),
"suffix": "".join(ch for ch in suffix if ch not in PROCESSOR_SUFFIXES),
"variant": variant, "capabilities": caps}
# ---------------------------------------------------------------- pricing (best effort)
_price_cache = {}
def spot_price(itype, az):
"""Latest spot price for (type, az), cached for the process lifetime.
Returns None when the aws CLI is missing or the call fails."""
key = (itype, az)
if key in _price_cache:
return _price_cache[key]
try:
out = json.loads(sh(
f"aws ec2 describe-spot-price-history --instance-types {itype} "
f"--availability-zone {az} --product-descriptions 'Linux/UNIX' "
f"--max-items 1 --output json", timeout=30))
p = out["SpotPriceHistory"][0]["SpotPrice"]
_price_cache[key] = float(p)
except Exception:
_price_cache[key] = None
return _price_cache[key]
# ---------------------------------------------------------------- commands
def cmd_nodes(store, args):
stories = build_stories(store)
live = live_nodeclaims()
ordered = sorted(stories.items(),
key=lambda kv: kv[1].t_created or datetime.min.replace(tzinfo=timezone.utc))
records = []
for name, s in ordered:
alive = name in live
if args.live and not alive:
continue
state = "RUNNING" if alive else ("DISRUPTED" if s.disruption else "GONE")
lifetime_s = None
if s.t_created:
end = s.t_deleted or datetime.now(timezone.utc)
lifetime_s = int((end - s.t_created).total_seconds())
records.append({
"nodeclaim": name, "node": s.node, "instance_type": s.instance_type,
"capacity_type": s.capacity_type, "zone": s.zone,
"instance_id": instance_id(s.provider_id), "status": state,
"nodepool": s.nodepool, "lifetime_seconds": lifetime_s,
})
if args.json:
print(json.dumps(records, indent=2))
return
if not records:
print("no karpenter nodes found (run some workloads, or `kexplain sync`)")
return
color = {"RUNNING": green, "DISRUPTED": red, "GONE": dim}
rows = [[
r["nodeclaim"], r["node"] or "-", r["instance_type"] or "?",
r["capacity_type"] or "?", r["zone"] or "?", r["instance_id"] or "-",
color[r["status"]](r["status"]),
fmt_dur(r["lifetime_seconds"]) if r["lifetime_seconds"] is not None else "",
] for r in records]
hdr = ["NODECLAIM", "NODE", "TYPE", "CAPACITY", "ZONE", "INSTANCE-ID", "STATUS", "LIFETIME"]
widths = [max(len(str(r[i])) if not str(r[i]).startswith("\033") else len(re.sub(r"\033\[\d+m", "", str(r[i])))
for r in [hdr] + rows) for i in range(len(hdr))]
def prow(r, is_hdr=False):
cells = []
for i, v in enumerate(r):
plain = re.sub(r"\033\[\d+m", "", str(v))
pad = " " * (widths[i] - len(plain))
cells.append(str(v) + pad)
line = " ".join(cells)
print(bold(line) if is_hdr else line)
prow(hdr, True)
for r in rows:
prow(r)
def _history_events(store):
"""All provisioning/disruption events as dicts with ts (datetime), kind,
and event-specific fields. Sorted by time. Shared by text and json output."""
stories = build_stories(store)
ev = []
for r in store.logs():
msg = r.get("message", "")
ts = parse_ts(r.get("time"))
if not ts:
continue
if msg == "found provisionable pod(s)":
pods = [p.strip() for p in r.get("Pods", "").split(",") if p.strip()]
ev.append({"ts": ts, "kind": "PENDING", "pods": pods})
elif msg == "computed new nodeclaim(s) to fit pod(s)":
ev.append({"ts": ts, "kind": "DECIDE", "pod_count": r.get("pods"),
"nodeclaim_count": r.get("nodeclaims")})
for name, s in stories.items():
if s.t_created:
ev.append({"ts": s.t_created, "kind": "CREATE", "nodeclaim": name,
"nodepool": s.nodepool, "candidate_types": s.candidate_count})
if s.t_launched:
ev.append({"ts": s.t_launched, "kind": "LAUNCH", "nodeclaim": name,
"instance_type": s.instance_type,
"capacity_type": s.capacity_type, "zone": s.zone})
if s.t_registered:
ev.append({"ts": s.t_registered, "kind": "REGISTER", "nodeclaim": name,
"node": s.node})
if s.t_initialized:
ev.append({"ts": s.t_initialized, "kind": "READY", "nodeclaim": name})
if s.disruption and s.disruption.get("ts"):
d = s.disruption
ev.append({"ts": d["ts"], "kind": "DISRUPT", "nodeclaim": name,
"reason": d.get("reason"), "decision": d.get("decision"),
"replacements": d.get("replacements", 0),
"savings_per_hour": d.get("savings")})
if s.t_deleted:
ev.append({"ts": s.t_deleted, "kind": "DELETE", "nodeclaim": name})
ev.sort(key=lambda e: e["ts"])
return ev
def _history_line(e):
k = e["kind"]
if k == "PENDING":
pods = e["pods"]
return (f'{yellow("PENDING")} {len(pods)} unschedulable pod(s): '
f'{", ".join(pods[:4])}{" …" if len(pods) > 4 else ""}')
if k == "DECIDE":
return (f'{cyan("DECIDE")} fit {e["pod_count"]} pod(s) onto '
f'{e["nodeclaim_count"]} new nodeclaim(s)')
if k == "CREATE":
return (f'{cyan("CREATE")} nodeclaim {bold(e["nodeclaim"])} '
f'(nodepool {e["nodepool"]}, {e["candidate_types"] or "?"} candidate types)')
if k == "LAUNCH":
return (f'{green("LAUNCH")} {bold(e["nodeclaim"])} → {e["instance_type"]} '
f'({e["capacity_type"]}) in {e["zone"]}')
if k == "REGISTER":
return f'{green("REGISTER")} {e["nodeclaim"]} joined as node {e["node"]}'
if k == "READY":
return f'{green("READY")} {e["nodeclaim"]} initialized'
if k == "DISRUPT":
sav = f', saves ${e["savings_per_hour"]:.2f}/hr' \
if e.get("savings_per_hour") is not None else ""
return (f'{magenta("DISRUPT")} {bold(e["nodeclaim"])} via {e.get("reason") or "?"} '
f'({e.get("decision") or "?"}, {e.get("replacements", 0)} replacement(s){sav})')
if k == "DELETE":
return f'{red("DELETE")} nodeclaim {e["nodeclaim"]} removed'
return k
def cmd_history(store, args):
events = _history_events(store)
if args.since:
cutoff = datetime.now(timezone.utc) - timedelta(hours=args.since)
events = [e for e in events if e["ts"] >= cutoff]
if args.json:
out = [{**{k: v for k, v in e.items() if k != "ts"},
"time": fmt_ts(e["ts"])} for e in events]
print(json.dumps(out, indent=2))
return
if not events:
print("no history yet")
return
last_day = None
for e in events:
day = e["ts"].strftime("%Y-%m-%d")
if day != last_day:
print(bold(f"\n── {day} " + "─" * 40))
last_day = day
print(f'{dim(e["ts"].strftime("%H:%M:%S"))} {_history_line(e)}')
def _tree(lines):
"""lines: list of (depth, text). Renders box-drawing tree."""
out = []
for i, (depth, text) in enumerate(lines):
if depth == 0:
out.append(text)
continue
# is this the last line at this depth before a shallower one?
last = True
for d2, _ in lines[i + 1:]:
if d2 < depth:
break
if d2 == depth:
last = False
break
prefix = ""
for d in range(1, depth):
# does any later line exist at depth d? then vertical bar
bar = False
for d2, _ in lines[i + 1:]:
if d2 < d:
break
if d2 == d:
bar = True
break
prefix += ("│ " if bar else " ")
prefix += "└─ " if last else "├─ "
out.append(dim(prefix) + text)
return "\n".join(out)
def resolve_target(stories, target):
"""target may be a node name, nodeclaim name, or instance id."""
if target in stories:
return stories[target]
for s in stories.values():
if s.node == target or instance_id(s.provider_id) == target:
return s
# prefix match
matches = [s for n, s in stories.items() if n.startswith(target)]
if len(matches) == 1:
return matches[0]
return None
def requirement_str(req):
op = req.get("operator", "")
vals = req.get("values", [])
key = req["key"]
if op == "In" and len(vals) == 1:
return f"{key} = {vals[0]}"
if op == "In":
return f"{key} in [{', '.join(vals)}]"
if op in ("Gt", "Lt", "Gte", "Lte"):
sym = {"Gt": ">", "Lt": "<", "Gte": ">=", "Lte": "<="}[op]
return f"{key} {sym} {vals[0]}"
if op == "Exists":
return f"{key} exists"
return f"{key} {op} {vals}"
def _nodeclass_for(store, s, pool):
"""The EC2NodeClass snapshot backing this node's NodePool, or None."""
ref = ((pool or {}).get("spec", {}).get("template", {}).get("spec", {})
.get("nodeClassRef", {}) or {})
name = ref.get("name")
classes = store.objects("ec2nodeclasses")
if name and name in classes:
return classes[name]
# fall back to the only class if there is exactly one
return next(iter(classes.values())) if len(classes) == 1 else None
def ami_compatible_types(cat, nodeclass):
"""Types kept by AMI compatibility: a type survives if it matches at least
one resolved AMI's requirements (Karpenter's provider-side pre-filter,
aws/karpenter-provider-aws FilterForNodeClass). Returns None when the
node class has no resolved AMIs in the store (nothing to compute against).
Auto Mode's NodeClass never publishes status.amis (AWS resolves the AMI
itself), so this returns None there and the funnel skips the stage rather
than filtering everything away."""
amis = (nodeclass or {}).get("status", {}).get("amis", [])
if not amis:
return None
def type_ok(info):
for ami in amis:
reqs = ami.get("requirements", [])
if all(match_requirement(info, r) for r in reqs):
return True
return False
return {n: i for n, i in cat.items() if type_ok(i)}
def zone_offerings(store, zones):
"""Set of instance types offered in any of `zones`, from a cached
describe-instance-type-offerings. Returns None if we cannot determine it
(no zones known, or no AWS access)."""
if not zones:
return None
cache = os.path.join(store.dir, "offerings.json")
offerings = {}
if os.path.exists(cache) and \
(datetime.now().timestamp() - os.path.getmtime(cache)) < EC2_CATALOG_TTL_S:
with open(cache) as f:
offerings = json.load(f)
missing = [z for z in zones if z not in offerings]
if missing:
for z in missing: