-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdefault_install_smoke.sh
More file actions
executable file
·4045 lines (3838 loc) · 221 KB
/
Copy pathdefault_install_smoke.sh
File metadata and controls
executable file
·4045 lines (3838 loc) · 221 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 bash
# SPDX-FileCopyrightText: 2026 The inference-cache Authors
#
# SPDX-License-Identifier: Apache-2.0
# Per-PR install smoke for `kubectl apply -k config/default`.
#
# Builds controller + server images at a deterministic tag, loads them into a
# kind cluster, installs cert-manager (same pinned version the C6 engine-wiring
# canary uses), renders config/default with the SHA-tagged images, applies it,
# and asserts the install actually came up:
#
# 1. inference-cache-controller-manager + inference-cache-server reach
# condition=Available within 120s.
# 2. The CacheIndex poller is writing status: `cacheindex/cluster-default`
# has a non-empty `.status.observedServer` within ~60s (one or two poll
# cycles past the 30s default refresh).
# 3. The server's operator HTTP surface is wired on the installed Service:
# `/readyz` returns 200, `/metrics` exposes `inferencecache_server_up 1`,
# and `kubectl get ci` renders the CacheIndex Prefixes/Changed printer
# columns.
# 4. The CachePolicy PUSH path works: an applied `CachePolicy` renders its
# operator-facing printer columns, the controller pushes it to the
# server's `/policy` endpoint, and `LookupRoute` observes ALL THREE
# policy enforcement paths without engine pods or inference traffic:
# the pushed `minimumPrefixTokens` request-side gate, the pushed
# `minimumMatchedTokens` per-replica result-side floor, and the
# pushed `routingFloorScore` whole-response score floor. Three
# orthogonal lookups exercise the first two (above both, below the
# request-side gate, sub-floor realized match); a follow-up patch +
# re-lookup pair exercises the routingFloorScore propagation and
# replace-on-write semantics. The installed validating webhook also
# rejects a SECOND CachePolicy in the namespace (one-per-namespace),
# proving the bundle's webhook Service + cert-manager CA-injection
# path — not just envtest handler logic.
# 5. The per-CacheTenant status projection works: an applied `CacheTenant`
# gets `.status.indexEntries=0` (observed-zero — no engine traffic in the
# smoke) and a `Ready=True` condition written by the same poller. The
# installed validating webhook also rejects a SECOND CacheTenant reusing
# an existing tenantID in the namespace (tenantID-uniqueness).
# 6. PromptTemplate + PDTopology are schema-only in the default install
# today: the manager registers their CRDs/RBAC but no status-writing
# reconciler. The smoke applies committed samples and asserts
# `kubectl get pt` / `kubectl get pdt` render their operator-facing
# printer columns.
# 7. The gRPC surface is reachable and PLAINTEXT by default: config/default
# serves :9090 plaintext (TLS is opt-in — phase 13), so a plaintext client
# lists services and a `LookupRoute` for an unknown model returns the
# fail-open default (`reason_code: NO_HINT`).
# 8. The CacheBackend ↔ engine-pod binding surfaces operators rely on
# actually wire up end-to-end: applying config/samples/cachebackend-
# with-engine.yaml drives status.matchedEnginePods=1, stamps the
# injected-by annotation on the engine pod, and surfaces the
# InjectedByCacheBackend Event (with the persisted pod UID — the
# regression that hides events from `kubectl describe pod`). Then
# the cache-server restart cascade: force-deleting the
# cache-server pod flips status.observedServerInstance to the
# replacement's server-instance identifier and patches the cascade-restart-trigger
# annotation onto the engine Deployment's pod template (the
# mechanism that drives the rolling restart). Finally scaling the
# engine to 0 drives status.matchedEnginePods=0 via the
# reconciler's self-RequeueAfter cadence (no CR or owned-workload
# event needed) within ~30s, the bound on stale-Matched the
# cadence guarantees.
# 8b. Provider resource fallback: the paired sample leaves
# remoteStorage.lmCacheServer.resources unset, while the provider renderer
# gives the cache-server container a 4Gi request / 8Gi limit. The smoke
# asserts the CR remains unchanged and the rendered pod is still bounded
# against the T2-write OOM failure mode.
# 8c. The canonical cache hierarchy keeps engine wiring and provider
# lifecycle independent: the committed SGLang host-only sample creates no
# Deployment/Service/HPA and publishes no endpoint, while the committed
# SGLang+Managed-Redis sample explicitly creates a redis-l2 Deployment +
# Service and publishes its RESP endpoint. No engine traffic is required.
# 9. Canonical External ownership end-to-end: applying the committed
# config/samples/cachebackend-external.yaml drives the CacheBackend
# mutating webhook default (spec.replicas=1), renders NO
# Deployment/Service in its namespace, status.endpoint mirrors
# spec.remoteStorage.endpoint, observedGeneration is set, the CR goes
# Ready=True/ExternalEndpointAccepted, and
# `kubectl get cb` renders the CacheBackend printer columns. A matching
# engine pod is admitted with
# `LMCACHE_REMOTE_URL=lm://<spec.remoteStorage.endpoint>`
# injected by the pod-mutating webhook. Also exercises admission
# validation rules (External with no endpoint, External with bad
# endpoint shape, and non-External + endpoint are rejected at write time),
# plus the scale-to-zero guard: a CacheBackend with spec.replicas=0 +
# spec.autoscaling enabled + nil spec.autoscaling.minReplicas is rejected
# at admission and NOT persisted (the operator-facing surface added by the
# defaulter-sweep; without the rule a "scale to zero" intent would silently
# become "scale 1-N" via the reconciler's HPA fallback).
# 9b. The Events-only CacheBackend mode (spec.integration.mode=EventsOnly)
# end-to-end: applying an events-only LMCache CacheBackend renders NO owned
# Deployment/Service, keeps status.endpoint empty, latches no
# firstKVEventObservedAt (no subscriber image wired → no KV events), and
# parks the CR at Ready=False/AwaitingFirstKVEvent via the same KV-event
# gate as a managed backend. The managed-only conditions (FunctionalProbeOK
# / EngineKernelsHealthy / T2Degraded / EngineCompatibility) are absent.
# Also exercises the validating webhook's EventsOnly+External rejection.
# 9c. Native SGLang HiCache end-to-end: applying the committed HiCache sample
# is observed by the controller without rendering a Deployment, Service, or
# HPA; status.endpoint stays empty and no synthetic Ready condition is
# published. A matching engine Pod sent through real server-side dry-run
# admission receives the complete native --hicache-* argument contract,
# proving the endpoint-free Pod webhook path without starting SGLang.
# 10. The /snapshot endpoint rejects unauthenticated callers AT THE NETWORK
# LAYER: a side curl pod outside the controller's SA identity (and outside
# the NetworkPolicy allowlist) has its connection to :8081 DROPPED by the
# server NetworkPolicy, so curl times out (curl_failed:28). The cluster
# runs a NetworkPolicy-enforcing CNI (Calico), so a bare HTTP 401 — the L7
# auth-middleware fallback — now FAILS the assertion: it would mean the
# NetworkPolicy was deleted/broken and only the auth middleware is left
# standing, the exact regression this gate exists to catch.
# 11. The /policy endpoint rejects unauthenticated callers at the network
# layer: same side-pod shape against the write-side endpoint. This is the
# more dangerous of the two — /policy is replace-on-write, so a successful
# unauthenticated POST would override every namespace's CachePolicy state
# cluster-wide. The probe POSTs a valid snapshot body so the rejection
# cannot be misattributed to a 400; the only valid outcome is the
# NetworkPolicy drop (curl_failed:28) — a bare 401 now FAILS.
# 11b. The /probe endpoint rejects unauthenticated callers at the network
# layer: same side-pod shape against the functional-self-test endpoint.
# /probe shares the controller ServiceAccount identity with /snapshot and
# /policy, so a regression that wired /probe outside that profile would let
# any pod that can reach :8081 drive a synthetic round-trip AND, since the
# CacheBackend reconciler now consumes the result to publish
# FunctionalProbeOK and downgrade Ready, observe or trigger forged Ready
# transitions on every managed backend. Sends a valid ProbeRequest body so
# the rejection cannot be misattributed to a 400; the only valid outcome is
# the NetworkPolicy drop (curl_failed:28) — a bare 401 now FAILS.
# 12. The audience binding holds on /snapshot, /policy, AND /probe: a probe
# pod with the controller's SA + labels reads three tokens
# (controller-audience projected, policy-audience projected, and the
# default-audience apiserver automount). It asserts the controller token
# admits on /snapshot + /probe, the policy token admits on /policy, and
# the default-audience token of the SAME SA is rejected everywhere; it
# also asserts the controller token cannot push /policy. Catches a
# regression in the SERVER's audience-enforcement half of the contract —
# `--controller-audience` / `--policy-audience` flag drift, the
# middleware forgetting to populate `TokenReviewSpec.Audiences`, or the
# apiserver mis-enforcing audience. Does NOT catch drift in the
# controller's production projected-volume manifest (the probe
# deliberately uses its own inline volume specs so it still runs when
# that manifest is broken); that drift is caught by item 2 above —
# observedServer populates only when the REAL controller's poller
# successfully scrapes /snapshot, and the CachePolicy adoption assertion
# passes only when the REAL controller's policy pusher reaches /policy.
# 12b. The authenticated /probe handler returns the expected default
# posture on a clean install: a controller-SA-authenticated POST
# gets HTTP 200 AND the parsed JSON body asserts ingest=ok,
# routing=ok, t2=skipped (no T2Prober is wired into the server
# today, so Stage C always reports skipped). A regression where
# the handler returns 200 with a per-stage "failed" would
# otherwise slip past the audience-binding phase above (which only
# checks HTTP status).
# 12c. The CacheTenant admission webhook rejects a CR claiming the
# server-reserved probe tenantID (inferencecache.io/probe). Pairs
# with the existing duplicate-tenantID assertion to pin BOTH
# CacheTenant validation rules end-to-end against the real
# installed webhook.
# 13. The opt-in gRPC TLS path works: applying config/overlays/server-tls
# (config/default + the config/server/tls component) rolls the server with
# --tls-cert-file/--tls-key-file + the cert-manager Secret. After rollout,
# a plaintext client is rejected and the cert-manager-issued chain +
# Service-FQDN SAN VERIFY against the CA published in the serving Secret
# (`grpcurl -cacert` with -authority <FQDN>; a wrong authority is
# rejected) — proving server authentication, not just encryption, for the
# overlay operators actually enable. Finally re-runs the SAME
# LookupRoute(unknown model) the plaintext phase (7) ran and asserts the
# identical fail-open NO_HINT, proving the existing call pattern is
# unchanged over TLS (pure transport wrapper, no contract/behavior change).
# 14. The LMCache kernel-check injection shape is correct end-to-end: a
# GPU-requesting engine pod (labeled app=kc-inject-engine, bound to a
# dedicated LMCache CacheBackend) is admitted and carries a
# lmcache-kernel-check init container whose image EQUALS the engine
# container's image (the adapter reuses it so no extra image pull
# occurs). Exercises the mutating pod webhook's auto mode (inject
# iff GPU requested) end-to-end on the real installed bundle.
# 15. The report-only FAIL condition path works fail-open: a dedicated
# LMCache CacheBackend (kc-cond) is annotated report-only, and a
# matching engine pod using python:3.11-slim runs the kernel-check
# init container, which exits 0 (fail-open) but writes "FAIL: lmcache
# not importable" to /dev/termination-log. The main container starts
# normally (pod Ready), proving report-only did not block the engine.
# The C2 reconciler reads the termination message and publishes
# EngineKernelsHealthy=False / reason=KernelLoadFailed on the
# CacheBackend status. The validating webhook also rejects an invalid
# lmcache-kernel-check annotation value (a typo would otherwise silently
# relax strict enforcement to report-only).
# 16. Every sample manifest under config/samples/ applies cleanly against
# the live install: a server-side dry-run apply of each *.yaml/*.yml
# exercises CRD structural validation + the validating admission webhook
# on the real cluster. Complements `make verify-samples` (which runs the
# same assertion at envtest level) by catching admission-wiring failures
# envtest masks — the webhook being unreachable/mis-wired on a real
# cluster (cert-manager caBundle injection) and the CRDs as actually
# installed by config/default. Mirrors verify-samples' sample set and
# honors its `# verify-samples: skip` opt-out so the two gates stay in
# lockstep. Admission-level only — does NOT create CRs, write status, or
# hit /policy+/snapshot (no NetworkPolicy/RBAC coverage; the per-CRD
# phases above cover those). No engine pods, no traffic.
# 17. The operator `inferencecache doctor` CLI runs end-to-end against the live
# install: build the binary, apply a CacheBackend, run the config-only
# checks, and assert it emits the documented JSON envelope, surfaces a
# CacheBackend (CB0xx) finding, and exits with a code matching the reported
# summary.exitCode (the CI-gating contract).
# 18. The managed Mooncake backend reconciles end-to-end: a busybox
# `mooncake_master` stand-in (accepts TCP on the RPC port so the rendered
# readiness probe passes — the real kvcacheai/mooncake image is NOT pulled)
# lets `CacheBackend{type: LMCache, remoteStorage.provider: Mooncake}`
# reach an Available Deployment, with
# `status.endpoint=<svc>:50051` and the Service's first port = the RPC port.
# Proves the real installed controller selects the vLLM/LMCache adapter with
# a Mooncake binding and renders the mooncake_master provider workload; the real
# engine-over-mooncakestore:// path stays for the Mooncake reference stack.
#
# Distinct from the C2/C6 canaries: those exercise real engine pods + cross-pod
# cache reuse (multi-GB image, ~10+ GiB RAM, schedule-only). This smoke stops
# at "the default install bundle wires together; gRPC fail-open works; the
# CacheBackend ↔ engine-pod binding surfaces operators rely on actually
# wire up end-to-end" -- light enough to run on every PR. The paired-sample
# phase swaps the engine container's image to busybox before pod CREATE and
# uses a tiny locally built lmcache_server stand-in for the managed cache
# server, so the smoke does not pay multi-GB pulls or depend on mutable
# upstream image availability; the signals it asserts materialize from pod
# CREATE and the controller-managed Deployment readiness surface.
#
# Designed to catch the class of install regression that surfaced when the
# default overlay was missing a Namespace resource: `kubectl apply -k` silently
# fails namespace-scoped creates on a clean cluster, and the heavier canaries'
# `wait --for=condition=Available` mask it.
#
# Prereqs (fresh kind cluster + this repo, nothing else):
# - docker (for `make image-build` and `kind load docker-image`)
# - kind (./bin/kind picked up if present, else `kind` on PATH)
# - kubectl
# - curl (probes the installed HTTP surface)
# - grpcurl (probes the gRPC surface)
# - kustomize (optional; sed fallback handles the image rewrite if absent)
#
# Usage: docs/reference-stack/scripts/default_install_smoke.sh
# Tunables: TAG, KIND_CLUSTER, NAMESPACE, CERT_MANAGER_VERSION, CALICO_VERSION,
# READY_TIMEOUT, CACHEINDEX_TIMEOUT, POLICY_PUSH_TIMEOUT, HTTP_LOCAL_PORT,
# GRPC_LOCAL_PORT, LOG_DIR, POLICY_SMOKE_NS, SAMPLE_NS,
# PROMPT_TOPOLOGY_SMOKE_NS, SAMPLE_ENDPOINT_TIMEOUT,
# SAMPLE_MATCH_TIMEOUT, SAMPLE_DRIFT_TIMEOUT,
# SAMPLE_CASCADE_TIMEOUT, SAMPLE_ENGINE_IMAGE,
# SAMPLE_CACHE_SERVER_IMAGE, CANONICAL_BACKEND_TIMEOUT,
# CANONICAL_SMOKE_NS, EXTERNAL_BACKEND_TIMEOUT,
# EXTERNAL_INJECT_TIMEOUT, EVENTSONLY_BACKEND_TIMEOUT,
# EVENTSONLY_SMOKE_NS, EVENTSONLY_SMOKE_CB_NAME, SAMPLE_APPLY_NS,
# HICACHE_SMOKE_TIMEOUT, HICACHE_SMOKE_NS, HICACHE_SMOKE_CB_NAME,
# KERNEL_CHECK_SMOKE_NS, KERNEL_CHECK_POD_TIMEOUT,
# KERNEL_CHECK_COND_TIMEOUT, MOONCAKE_SMOKE_NS, MOONCAKE_MASTER_IMAGE.
set -euo pipefail
TAG="${TAG:-${GITHUB_SHA:-$(git rev-parse HEAD)}}"
KIND_CLUSTER="${KIND_CLUSTER:-ic-install-smoke}"
NAMESPACE="${NAMESPACE:-inference-cache-system}"
CERT_MANAGER_VERSION="${CERT_MANAGER_VERSION:-v1.16.1}"
# NetworkPolicy-enforcing CNI installed in place of kind's default kindnet (which
# does NOT enforce NetworkPolicy). Required so the server NetworkPolicy actually
# drops unauthenticated traffic to :8081 and the /snapshot + /policy + /probe
# assertions below can require the L3/L4 drop. Calico's default IPv4
# pool is 192.168.0.0/16 — the kind podSubnet is set to match (see cluster block).
CALICO_VERSION="${CALICO_VERSION:-v3.28.2}"
READY_TIMEOUT="${READY_TIMEOUT:-120s}"
CACHEINDEX_TIMEOUT="${CACHEINDEX_TIMEOUT:-90}" # seconds; ~3x the 30s refresh, absorbs leader-election + first-tick jitter
POLICY_PUSH_TIMEOUT="${POLICY_PUSH_TIMEOUT:-90}" # seconds; watch-triggered push + one 30s periodic repair tick
HTTP_LOCAL_PORT="${HTTP_LOCAL_PORT:-18080}"
GRPC_LOCAL_PORT="${GRPC_LOCAL_PORT:-19090}"
LOG_DIR="${LOG_DIR:-/tmp/install-smoke-logs}"
# External-backend gate timeouts. The reconciler patches status on the next
# reconcile (sub-second on a fresh CR), and the pod webhook resolves the
# endpoint synchronously at admission, so these are short. The values give
# headroom for the initial APIReader cache warm-up and the leader-election
# lease the External-reconcile path inherits from the C2 reconciler loop.
EXTERNAL_BACKEND_TIMEOUT="${EXTERNAL_BACKEND_TIMEOUT:-30}" # seconds
EXTERNAL_INJECT_TIMEOUT="${EXTERNAL_INJECT_TIMEOUT:-30}" # seconds
CANONICAL_BACKEND_TIMEOUT="${CANONICAL_BACKEND_TIMEOUT:-30}" # seconds
# Events-only smoke tunable. An events-only backend provisions no workload, so
# the only wait is the reconciler latching status.firstAvailableAt and the
# KV-event gate publishing Ready=False/AwaitingFirstKVEvent — a sub-second
# server-less reconcile; the budget covers APIReader warm-up + leader-election.
EVENTSONLY_BACKEND_TIMEOUT="${EVENTSONLY_BACKEND_TIMEOUT:-30}" # seconds
HICACHE_SMOKE_TIMEOUT="${HICACHE_SMOKE_TIMEOUT:-30}" # seconds
# Kernel-check smoke tunables (assertions 14 + 15).
# KERNEL_CHECK_SMOKE_NS is a dedicated namespace created + deleted by those
# two phases so they don't leave fixtures in other namespaces.
KERNEL_CHECK_SMOKE_NS="${KERNEL_CHECK_SMOKE_NS:-ic-smoke-kernel-check}"
# Budget for the report-only engine pod to become Ready (init container runs
# python:3.11-slim; the image pull dominates on a cold node but is small).
KERNEL_CHECK_POD_TIMEOUT="${KERNEL_CHECK_POD_TIMEOUT:-120}"
# Budget for the C2 reconciler to read the init-container termination message
# and publish EngineKernelsHealthy=False. One reconcile cycle + poll buffer.
KERNEL_CHECK_COND_TIMEOUT="${KERNEL_CHECK_COND_TIMEOUT:-60}"
# Sample-smoke tunables — apply config/samples/cachebackend-with-engine.yaml,
# assert the operator-facing signals, exercise the RequeueAfter drift case.
#
# Default namespace is dedicated to this smoke so re-runs against an existing
# cluster (KEEP_CLUSTER=1) don't mutate or delete a developer's own resources
# in `default`. The script creates the namespace on entry and deletes it on
# the way out.
SAMPLE_NS="${SAMPLE_NS:-cb-engine-smoke}"
POLICY_SMOKE_NS="${POLICY_SMOKE_NS:-ic-smoke-policy}"
PROMPT_TOPOLOGY_SMOKE_NS="${PROMPT_TOPOLOGY_SMOKE_NS:-ic-smoke-prompt-topology}"
# CacheBackend reconciler publishes status.endpoint once the managed
# lmcache-server Service is created — typically within ~5s. 60s absorbs
# cold-start jitter.
SAMPLE_ENDPOINT_TIMEOUT="${SAMPLE_ENDPOINT_TIMEOUT:-60}"
# Reconciler runs initial CacheBackend reconcile + first Matched refresh
# within a few seconds of CB Create. 60s absorbs cold-start jitter.
SAMPLE_MATCH_TIMEOUT="${SAMPLE_MATCH_TIMEOUT:-60}"
# Drift case waits for the 30s self-RequeueAfter cadence to fire after the
# engine pod is gone. 75s = one full cadence + buffer for the patch + pod-
# terminate round-trip.
SAMPLE_DRIFT_TIMEOUT="${SAMPLE_DRIFT_TIMEOUT:-75}"
# Cache-server restart cascade. Each wait covers a different leg of
# the loop: the controller observing the replacement cache-server pod
# and computing its server-instance identifier
# (`<pod-uid>:<restart-sum>`), then patching the engine Deployment's
# pod template annotations. 60s absorbs the cache-server pod's
# recreate-and-Ready cycle (the busybox stand-in starts in a few
# seconds; the wait dominates on a cold node).
SAMPLE_CASCADE_TIMEOUT="${SAMPLE_CASCADE_TIMEOUT:-60}"
# KV-event gate: time budget for the managed cache-server Deployment to pull
# its image and reach Available, then for the gate to publish
# AwaitingFirstKVEvent. The image pull dominates on a cold node, hence the
# larger default than the other sample waits.
SAMPLE_GATE_TIMEOUT="${SAMPLE_GATE_TIMEOUT:-240}"
# Tiny stand-in for the vLLM image. The webhook injects on pod CREATE; the
# engine doesn't need to run for the operator-facing signals (Matched,
# annotation, Event) to materialize. Avoids a multi-GB pull in CI.
SAMPLE_ENGINE_IMAGE="${SAMPLE_ENGINE_IMAGE:-busybox:1.36}"
# Tiny stand-in for the managed LMCache server image. The controller still
# renders the canonical lmcache_server command/args and TCP readiness probe; the
# image only provides a local binary that listens on the requested port so the
# Deployment can become Available without pulling lmcache/standalone:v0.4.7.
SAMPLE_CACHE_SERVER_IMAGE="${SAMPLE_CACHE_SERVER_IMAGE:-install-smoke-lmcache-server:$TAG}"
# Image refs match the Makefile's REGISTRY/repo defaults so `kustomize edit set
# image` (or the sed fallback) rewrites the in-tree controller=/server= entries
# without changing their registry/repo paths.
REGISTRY="${REGISTRY:-ghcr.io/cachebox-project}"
CONTROLLER_IMG="$REGISTRY/inference-cache-controller:$TAG"
SERVER_IMG="$REGISTRY/inference-cache-server:$TAG"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
cd "$REPO_ROOT"
# External-backend smoke fixture identifiers. Declared up front so the
# diagnostics helper can reference them even if the smoke aborts before
# the External section creates the objects.
EXT_SMOKE_NS="${EXT_SMOKE_NS:-ic-smoke-external}"
EXT_SMOKE_CB_NAME="cachebackend-external"
EXT_SMOKE_POD_NAME="${EXT_SMOKE_POD_NAME:-smoke-engine}"
# Canonical hierarchy fixtures: one host-only and one explicitly managed
# provider in the same disposable namespace.
CANONICAL_SMOKE_NS="${CANONICAL_SMOKE_NS:-ic-smoke-canonical-cache}"
CANONICAL_HOST_ONLY_CB="cachebackend-sglang-host-only"
CANONICAL_REDIS_CB="cachebackend-sglang"
# Events-only-backend smoke fixture identifiers. Declared up front so the
# diagnostics helper can reference them even if the smoke aborts before the
# events-only section creates the objects.
EVENTSONLY_SMOKE_NS="${EVENTSONLY_SMOKE_NS:-ic-smoke-events-only}"
EVENTSONLY_SMOKE_CB_NAME="${EVENTSONLY_SMOKE_CB_NAME:-cachebackend-events-only}"
# Native SGLang HiCache fixture identifiers. This engine-local backend has no
# endpoint or controller-owned workload, so its dedicated namespace should
# contain only the persisted CacheBackend used by the smoke.
HICACHE_SMOKE_NS="${HICACHE_SMOKE_NS:-ic-smoke-sglang-hicache}"
HICACHE_SMOKE_CB_NAME="${HICACHE_SMOKE_CB_NAME:-sglang-hicache}"
KIND="${KIND:-$([ -x ./bin/kind ] && echo ./bin/kind || echo kind)}"
pf_pid=""
http_pf_pid=""
tmpdir=""
kind_config_file=""
log() { echo "[install-smoke] $*"; }
fail() {
echo "[install-smoke] FAIL: $*" >&2
collect_diagnostics || true
exit 1
}
collect_diagnostics() {
mkdir -p "$LOG_DIR"
log "collecting diagnostics into $LOG_DIR"
kubectl get pods -A -o wide >"$LOG_DIR/pods-all.txt" 2>&1 || true
kubectl -n "$NAMESPACE" describe deployment/inference-cache-controller-manager \
>"$LOG_DIR/describe-controller.txt" 2>&1 || true
kubectl -n "$NAMESPACE" describe deployment/inference-cache-server \
>"$LOG_DIR/describe-server.txt" 2>&1 || true
kubectl -n "$NAMESPACE" logs deployment/inference-cache-controller-manager --all-containers --tail=-1 \
>"$LOG_DIR/logs-controller.txt" 2>&1 || true
kubectl -n "$NAMESPACE" logs deployment/inference-cache-server --all-containers --tail=-1 \
>"$LOG_DIR/logs-server.txt" 2>&1 || true
kubectl get cacheindex cluster-default -o yaml \
>"$LOG_DIR/cacheindex.yaml" 2>&1 || true
kubectl get cachetenants -A -o yaml \
>"$LOG_DIR/cachetenants.yaml" 2>&1 || true
kubectl get cachepolicies -A -o yaml \
>"$LOG_DIR/cachepolicies.yaml" 2>&1 || true
kubectl get prompttemplates -A -o yaml \
>"$LOG_DIR/prompttemplates.yaml" 2>&1 || true
kubectl get pdtopologies -A -o yaml \
>"$LOG_DIR/pdtopologies.yaml" 2>&1 || true
kubectl -n cert-manager get pods -o wide \
>"$LOG_DIR/cert-manager-pods.txt" 2>&1 || true
# Calico CNI state — the smoke swaps kindnet for Calico so NetworkPolicy is
# enforced, so a stuck pod sandbox or an inert policy usually traces back to
# the CNI. Capture node readiness + calico-node/kube-controllers describe+logs.
# Best-effort (|| true): absent on a pre-Calico abort.
kubectl get nodes -o wide \
>"$LOG_DIR/nodes.txt" 2>&1 || true
kubectl -n kube-system get pods -l k8s-app=calico-node -o wide \
>"$LOG_DIR/calico-node-pods.txt" 2>&1 || true
kubectl -n kube-system describe daemonset calico-node \
>"$LOG_DIR/calico-node-describe.txt" 2>&1 || true
kubectl -n kube-system logs -l k8s-app=calico-node --all-containers --tail=-1 \
>"$LOG_DIR/calico-node-logs.txt" 2>&1 || true
kubectl -n kube-system describe deployment calico-kube-controllers \
>"$LOG_DIR/calico-kube-controllers-describe.txt" 2>&1 || true
kubectl -n kube-system logs deployment/calico-kube-controllers --tail=-1 \
>"$LOG_DIR/calico-kube-controllers-logs.txt" 2>&1 || true
# Paired-sample state — only populated if the sample-smoke phase ran;
# safe (|| true) if it didn't.
kubectl -n "$SAMPLE_NS" get cb -o yaml \
>"$LOG_DIR/sample-cb.yaml" 2>&1 || true
kubectl -n "$SAMPLE_NS" get pod -l app=qwen-demo -o yaml \
>"$LOG_DIR/sample-pods.yaml" 2>&1 || true
kubectl -n "$SAMPLE_NS" get events.events.k8s.io -o yaml \
>"$LOG_DIR/sample-events.yaml" 2>&1 || true
kubectl -n "$CANONICAL_SMOKE_NS" get cb -o yaml \
>"$LOG_DIR/canonical-cachebackends.yaml" 2>&1 || true
kubectl -n "$CANONICAL_SMOKE_NS" get deploy,svc,hpa -o yaml \
>"$LOG_DIR/canonical-provider-workloads.yaml" 2>&1 || true
# External-backend smoke artefacts. Best-effort — the CR/pod may not
# exist if the smoke aborted before that section.
kubectl get cb -A -o wide \
>"$LOG_DIR/cachebackends.txt" 2>&1 || true
kubectl get cb -A -o yaml \
>"$LOG_DIR/cachebackends.yaml" 2>&1 || true
kubectl get cb -n "$EXT_SMOKE_NS" "$EXT_SMOKE_CB_NAME" -o yaml \
>"$LOG_DIR/external-cb.yaml" 2>&1 || true
kubectl get pod -n "$EXT_SMOKE_NS" "$EXT_SMOKE_POD_NAME" -o yaml \
>"$LOG_DIR/external-engine-pod.yaml" 2>&1 || true
kubectl get deploy,svc -n "$EXT_SMOKE_NS" \
>"$LOG_DIR/external-ns-workloads.txt" 2>&1 || true
# Events-only-backend smoke artefacts. Best-effort — the CR may not exist if
# the smoke aborted before that section.
kubectl get cb -n "$EVENTSONLY_SMOKE_NS" "$EVENTSONLY_SMOKE_CB_NAME" -o yaml \
>"$LOG_DIR/events-only-cb.yaml" 2>&1 || true
kubectl get deploy,svc -n "$EVENTSONLY_SMOKE_NS" \
>"$LOG_DIR/events-only-ns-workloads.txt" 2>&1 || true
# Native SGLang HiCache smoke artefacts. Best-effort — the CR may not exist
# if the smoke aborted before that section.
kubectl get cb -n "$HICACHE_SMOKE_NS" "$HICACHE_SMOKE_CB_NAME" -o yaml \
>"$LOG_DIR/sglang-hicache-cb.yaml" 2>&1 || true
kubectl get deploy,svc,hpa -n "$HICACHE_SMOKE_NS" \
>"$LOG_DIR/sglang-hicache-ns-workloads.txt" 2>&1 || true
# Kernel-check smoke artefacts. Best-effort — the objects may not
# exist if the smoke aborted before that section.
kubectl get cb -n "$KERNEL_CHECK_SMOKE_NS" -o yaml \
>"$LOG_DIR/kernel-check-cachebackends.yaml" 2>&1 || true
kubectl get pod -n "$KERNEL_CHECK_SMOKE_NS" -o yaml \
>"$LOG_DIR/kernel-check-pods.yaml" 2>&1 || true
kubectl get events.events.k8s.io -n "$KERNEL_CHECK_SMOKE_NS" \
>"$LOG_DIR/kernel-check-events.txt" 2>&1 || true
}
cleanup() {
[ -n "$pf_pid" ] && kill "$pf_pid" 2>/dev/null || true
[ -n "$http_pf_pid" ] && kill "$http_pf_pid" 2>/dev/null || true
[ -n "$tmpdir" ] && rm -rf "$tmpdir"
[ -n "$kind_config_file" ] && rm -f "$kind_config_file"
# Only tear the cluster down if we created it (lets local devs pre-create a
# cluster and re-run the smoke without paying the create cost each time).
if [ "${KEEP_CLUSTER:-0}" != "1" ] && [ "${CREATED_CLUSTER:-0}" = "1" ]; then
"$KIND" delete cluster --name "$KIND_CLUSTER" >/dev/null 2>&1 || true
fi
}
# Catch ANY non-zero exit, not just the ones routed through fail(), and dump
# diagnostics BEFORE cleanup deletes the cluster. Without this, a `set -e`
# abort from an unwrapped command (kubectl apply -k, make image-build, kind
# load, the cert-manager apply) tears the cluster down with no artifact left
# behind -- which is exactly the case (e.g. a missing Namespace resource in
# config/default) this gate is meant to surface.
on_exit() {
local rc=$?
if [ "$rc" -ne 0 ]; then
collect_diagnostics || true
fi
cleanup
}
trap on_exit EXIT
# --- prereq checks ----------------------------------------------------------
for bin in docker kubectl curl grpcurl "$KIND"; do
command -v "$bin" >/dev/null 2>&1 || fail "missing required tool on PATH: $bin"
done
build_sample_cache_server_image() {
local context
context="$(mktemp -d "$tmpdir/lmcache-server-context.XXXXXX")"
cat >"$context/lmcache_server" <<'EOF'
#!/bin/sh
port="${2:-65432}"
while true; do
nc -l -p "$port" >/dev/null 2>&1 || sleep 1
done
EOF
cat >"$context/Dockerfile" <<'EOF'
FROM busybox:1.36
COPY lmcache_server /usr/local/bin/lmcache_server
RUN chmod +x /usr/local/bin/lmcache_server
EOF
log "building lightweight lmcache_server stand-in image=$SAMPLE_CACHE_SERVER_IMAGE"
docker build -t "$SAMPLE_CACHE_SERVER_IMAGE" "$context"
log "loading $SAMPLE_CACHE_SERVER_IMAGE into the kind node"
"$KIND" load docker-image "$SAMPLE_CACHE_SERVER_IMAGE" --name "$KIND_CLUSTER"
}
# Install Calico as a NetworkPolicy-enforcing CNI, then block until it — and the
# node + CoreDNS it unblocks — are fully Ready. kind's built-in kindnet CNI does
# NOT enforce NetworkPolicy, so with kindnet the server NetworkPolicy is inert
# and the /snapshot + /policy + /probe drop assertions can only ever pass on the
# L7 401 fallback. A half-initialised CNI is the main flakiness risk of this
# swap, so every component is waited on explicitly.
#
# Idempotent: `kubectl apply` and every rollout/wait below is a no-op on a
# cluster that already has Calico Ready, so this is safe to call on the reuse
# path as well as after a fresh create.
#
# Timeout budget: the waits sum to 180+120+90+120 = 510s worst case, kept well
# under the workflow's timeout-minutes so that even a wedged CNI fails with time
# to spare for the exit trap to collect diagnostics before GitHub Actions SIGKILLs
# the job. In practice Calico is Ready in well under a minute; these are ceilings.
install_calico() {
log "installing Calico $CALICO_VERSION (NetworkPolicy-enforcing CNI)"
kubectl apply -f \
"https://raw.githubusercontent.com/projectcalico/calico/$CALICO_VERSION/manifests/calico.yaml"
log "waiting for Calico components to become Ready"
# calico-node is the per-node DaemonSet that programs the dataplane and
# enforces NetworkPolicy; calico-kube-controllers is the policy/IPAM
# controller. rollout status blocks until desired == ready for each.
kubectl -n kube-system rollout status daemonset/calico-node --timeout=180s
kubectl -n kube-system rollout status deployment/calico-kube-controllers --timeout=120s
# The node stays NotReady until the CNI is actually programming the dataplane,
# so node-Ready is the authoritative "Calico works" gate (it also backstops the
# DaemonSet rollout racing to a premature 0-desired success). CoreDNS only gets
# a pod IP once the CNI is up, and the probes below resolve the server Service
# through it, so gate on CoreDNS too.
kubectl wait --for=condition=Ready nodes --all --timeout=90s
kubectl -n kube-system rollout status deployment/coredns --timeout=120s
log "Calico is Ready; NetworkPolicy enforcement is active"
}
# --- cluster ----------------------------------------------------------------
# The default-install smoke requires a NetworkPolicy-ENFORCING CNI so the server
# NetworkPolicy actually drops unauthenticated traffic to the server's :8081
# controller-facing listener (/snapshot, /policy, /probe) — see install_calico
# above and the tightened assertions later in this script. This CNI
# swap is scoped to CI: the human-facing operator reference cluster
# (docs/reference-stack/kind/cluster.yaml) is intentionally left on kindnet — it
# demos the substrate and does not need NetworkPolicy enforcement.
if "$KIND" get clusters 2>/dev/null | grep -qx "$KIND_CLUSTER"; then
log "reusing existing kind cluster $KIND_CLUSTER"
CREATED_CLUSTER=0
else
log "creating kind cluster $KIND_CLUSTER with the default CNI disabled (Calico installed below)"
# disableDefaultCNI drops kindnet; podSubnet matches Calico's default IPv4 pool
# (192.168.0.0/16) so calico-node hands out addresses from the same range
# kube-controller-manager allocates node podCIDRs from — the canonical
# kind+Calico pairing, no IP-pool patching needed. No `--wait` here: with the
# default CNI disabled the node stays NotReady until Calico is up, so readiness
# is waited on inside install_calico below.
kind_config_file="$(mktemp)"
cat >"$kind_config_file" <<'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
networking:
disableDefaultCNI: true
podSubnet: "192.168.0.0/16"
EOF
"$KIND" create cluster --name "$KIND_CLUSTER" --config "$kind_config_file"
rm -f "$kind_config_file"
kind_config_file=""
CREATED_CLUSTER=1
fi
kubectl config use-context "kind-$KIND_CLUSTER" >/dev/null
# Guard the reuse path (CREATED_CLUSTER=0). A reused cluster (KEEP_CLUSTER=1) must
# ALREADY be on Calico ALONE, configured the way the create path above sets it up
# — one created by this script is (default CNI disabled + 192.168.0.0/16 pool).
# Anything else enforces NetworkPolicy unreliably and would give the tightened
# drop probes false results, so verify the actual CNI and pod CIDR and bail with
# recreate guidance rather than layering Calico on top / silently degrading:
# 1. kindnet gone — a pure-kindnet cluster OR the dual-CNI state of kindnet with
# Calico added on top both enforce unreliably (checking calico-node presence
# alone would wave the dual-CNI case through);
# 2. calico-node present — an enforcing CNI actually exists;
# 3. node pod CIDR is 192.168.0.0/16 — a stale cluster on kindnet's default
# 10.244.0.0/16 would mismatch Calico's pool.
if [ "$CREATED_CLUSTER" = "0" ]; then
reuse_fix="Delete it ('$KIND delete cluster --name $KIND_CLUSTER') and re-run so the script recreates it with the default CNI disabled + the 192.168.0.0/16 pod CIDR, or unset KEEP_CLUSTER."
if kubectl -n kube-system get daemonset kindnet >/dev/null 2>&1; then
fail "reused kind cluster $KIND_CLUSTER still has the kindnet CNI (kindnet DaemonSet present in kube-system) — this smoke needs Calico as the SOLE NetworkPolicy-enforcing CNI; a kindnet-only or kindnet+Calico cluster enforces unreliably. $reuse_fix"
fi
if ! kubectl -n kube-system get daemonset calico-node >/dev/null 2>&1; then
fail "reused kind cluster $KIND_CLUSTER has no calico-node DaemonSet in kube-system — no NetworkPolicy-enforcing CNI is installed. $reuse_fix"
fi
reuse_pod_cidr="$(kubectl get nodes -o jsonpath='{.items[0].spec.podCIDR}' 2>/dev/null || true)"
case "$reuse_pod_cidr" in
192.168.*) ;;
*) fail "reused kind cluster $KIND_CLUSTER has pod CIDR '$reuse_pod_cidr', not the 192.168.0.0/16 Calico pool this smoke configures. $reuse_fix" ;;
esac
fi
# Install (or verify) Calico. install_calico is idempotent, so on a reused
# Calico cluster the apply is a no-op and the readiness waits return
# immediately; on a freshly-created cluster it brings the CNI up. Running it on
# both paths keeps the tightened /snapshot + /policy + /probe drop probes from
# ever executing against a non-enforcing CNI.
install_calico
# --- build + load images ----------------------------------------------------
log "building controller + server images at TAG=$TAG"
make image-build TAG="$TAG" REGISTRY="$REGISTRY"
log "loading $CONTROLLER_IMG into the kind node"
"$KIND" load docker-image "$CONTROLLER_IMG" --name "$KIND_CLUSTER"
log "loading $SERVER_IMG into the kind node"
"$KIND" load docker-image "$SERVER_IMG" --name "$KIND_CLUSTER"
# --- cert-manager -----------------------------------------------------------
log "installing cert-manager $CERT_MANAGER_VERSION"
kubectl apply -f \
"https://github.com/cert-manager/cert-manager/releases/download/$CERT_MANAGER_VERSION/cert-manager.yaml"
kubectl -n cert-manager wait --for=condition=Available deployment --all --timeout=180s
# --- render config/default with SHA-tagged images --------------------------
# Don't mutate the tracked kustomization.yaml -- copy the whole config tree into
# a tmpdir and edit there. Prefer `kustomize edit set image` when the binary is
# on PATH; sed fallback (scoped to each `- name:` block) keeps the script
# self-contained on a fresh laptop without the kustomize CLI installed.
tmpdir="$(mktemp -d)"
cp -r config "$tmpdir/config"
(
cd "$tmpdir/config/default"
if command -v kustomize >/dev/null 2>&1; then
kustomize edit set image \
"controller=$CONTROLLER_IMG" \
"server=$SERVER_IMG"
else
# Each `- name: …` block is followed by `newName:` + `newTag:`. Split on
# the LAST `:` so registry-with-port refs (host:port/repo:tag) keep their
# registry path; `${X%:*}` strips the shortest suffix from the final `:`.
sed -i.bak \
-e "/^- name: controller$/,/^- name: server$/ {
s|^ newName: .*| newName: ${CONTROLLER_IMG%:*}|
s|^ newTag: .*| newTag: ${CONTROLLER_IMG##*:}|
}" \
-e "/^- name: server$/,\$ {
s|^ newName: .*| newName: ${SERVER_IMG%:*}|
s|^ newTag: .*| newTag: ${SERVER_IMG##*:}|
}" \
kustomization.yaml
fi
)
# --- apply + wait -----------------------------------------------------------
log "applying config/default (controller + server + CRDs + RBAC + webhook)"
kubectl apply -k "$tmpdir/config/default"
log "waiting up to $READY_TIMEOUT for controller + server deployments to reach Available"
kubectl -n "$NAMESPACE" wait --for=condition=Available --timeout="$READY_TIMEOUT" \
deployment/inference-cache-controller-manager \
deployment/inference-cache-server \
|| fail "controller and/or server did not reach Available within $READY_TIMEOUT"
# --- server resources sized for DefaultMaxEntries ---------------------------
# The default install MUST budget enough memory to actually hold the
# DefaultMaxEntries=1,000,000 cap; without that, the default cap is a
# meaningless number — operators would OOM well before reaching it. The
# sizing-guide measurements (docs/operations/index-sizing.md) put 1M
# entries at ~540 MiB peak RSS, so the limit lives at 1Gi. Asserting on
# the live Deployment proves the bundle still ships that resource shape —
# the smoke would catch a future refactor that "simplified" the limit
# back to its old 256Mi value, which would silently re-introduce the
# OOM-below-cap discrepancy.
server_mem_limit=$(kubectl -n "$NAMESPACE" get deployment/inference-cache-server \
-o jsonpath='{.spec.template.spec.containers[?(@.name=="server")].resources.limits.memory}' \
2>/dev/null || true)
# Normalize to bytes so the assertion catches semantic drift, not literal-string
# drift: K8s quantities like "1Gi" and "1024Mi" are equivalent and either is a
# valid way to express the documented 1 GiB. The minimum sized to fit the
# DefaultMaxEntries=1M cap at ~540 MiB peak RSS plus a 1.5x headroom margin is
# 1 GiB = 1073741824 bytes; we accept anything >= that.
mem_to_bytes() {
# Strips a K8s memory quantity suffix (Ki/Mi/Gi/Ti or k/M/G/T) and emits bytes.
# Returns 0 on unparseable input — caller treats 0 as "below threshold" and
# fails noisily. Uses awk for the multiply so fractional quantities like
# 1.5Gi don't trip bash integer arithmetic (which would crash the gate
# instead of failing it cleanly).
local v="$1" n factor
case "$v" in
*Ki) n=${v%Ki}; factor=1024 ;;
*Mi) n=${v%Mi}; factor=$((1024 * 1024)) ;;
*Gi) n=${v%Gi}; factor=$((1024 * 1024 * 1024)) ;;
*Ti) n=${v%Ti}; factor=$((1024 * 1024 * 1024 * 1024)) ;;
*k) n=${v%k}; factor=1000 ;;
*M) n=${v%M}; factor=$((1000 * 1000)) ;;
*G) n=${v%G}; factor=$((1000 * 1000 * 1000)) ;;
*T) n=${v%T}; factor=$((1000 * 1000 * 1000 * 1000)) ;;
*) n=$v; factor=1 ;;
esac
awk -v n="$n" -v f="$factor" 'BEGIN {
# awk parses leading numerics; "garbage" becomes 0, "1.5" stays 1.5.
# printf "%.0f" rounds the product back to an integer byte count.
printf "%.0f\n", n * f
}'
}
server_mem_bytes=$(mem_to_bytes "$server_mem_limit")
min_bytes=$(( 1024 * 1024 * 1024 )) # 1 GiB
if [ "$server_mem_bytes" -lt "$min_bytes" ]; then
fail "inference-cache-server memory limit = '$server_mem_limit' ($server_mem_bytes bytes); want >= 1Gi ($min_bytes bytes) to fit DefaultMaxEntries=1M per docs/operations/index-sizing.md"
fi
log "inference-cache-server memory limit = $server_mem_limit ($server_mem_bytes bytes; >= 1Gi → sized for DefaultMaxEntries=1M)"
# --- CacheBackend CRD schema-trim assertion --------------------------------
# The installed CRD must reflect the inert-field trim: the five removed fields
# are absent from the served v1alpha1 schema, and the field that replaced the
# removed status.indexEntries — status.indexParticipation.prefixCount — is
# present. Probing the live CRD in the cluster (not just the repo manifest)
# proves the trimmed schema is what actually got installed by `kubectl apply
# -k`. Each probe asks for a field's `.type`: absent fields yield empty output,
# present fields yield their OpenAPI type — unambiguous and free of
# map-formatting quirks.
crd_field_type() {
# $1 = jsonpath under the v1alpha1 openAPIV3Schema.properties root
kubectl get crd cachebackends.inferencecache.io \
-o "jsonpath={.spec.versions[?(@.name=='v1alpha1')].schema.openAPIV3Schema.properties.$1.type}" \
2>/dev/null || true
}
if [ -n "$(crd_field_type 'spec.properties.integration.properties.lookupTimeoutMs')" ]; then
fail "CRD still serves removed spec.integration.lookupTimeoutMs (schema trim not installed)"
fi
if [ -n "$(crd_field_type 'spec.properties.integration.properties.minimumPrefixTokens')" ]; then
fail "CRD still serves removed spec.integration.minimumPrefixTokens (schema trim not installed)"
fi
if [ -n "$(crd_field_type 'status.properties.indexEntries')" ]; then
fail "CRD still serves removed status.indexEntries (schema trim not installed)"
fi
# status.indexParticipation.prefixCount is the authoritative count surface that
# replaced the removed status.indexEntries — assert the replacement is served.
if [ -z "$(crd_field_type 'status.properties.indexParticipation.properties.prefixCount')" ]; then
fail "CRD is missing status.indexParticipation.prefixCount (the replacement for status.indexEntries)"
fi
# status.indexParticipation.t2HitRate is the tier-2 (LMCache) offload health
# surface — assert the new status field is actually served by the installed CRD.
if [ -z "$(crd_field_type 'status.properties.indexParticipation.properties.t2HitRate')" ]; then
fail "CRD is missing status.indexParticipation.t2HitRate (the tier-2 health surface)"
fi
# spec.storage{,.pvc} + status.capacity were removed in the storage-retirement
# trim — the lm:// server we provision is in-memory, so a local PVC cannot
# honestly back it; durability is a backend choice. Assert the installed CRD no
# longer serves them, so an operator cannot set a storage field the controller
# no longer honors (the operator-facing surface change this smoke must catch).
if [ -n "$(crd_field_type 'spec.properties.storage')" ]; then
fail "CRD still serves removed spec.storage (storage-retirement trim not installed)"
fi
if [ -n "$(crd_field_type 'status.properties.capacity')" ]; then
fail "CRD still serves removed status.capacity (storage-retirement trim not installed)"
fi
log "CacheBackend CRD reflects the schema trim (lookupTimeoutMs/minimumPrefixTokens/indexEntries/storage/capacity absent; indexParticipation.prefixCount + t2HitRate present)"
# --- CacheIndex poller assertion -------------------------------------------
# The controller's CacheIndex poller is leader-elected and refreshes on a 30s
# ticker, so a non-empty observedServer within ~60s of Available proves the
# poller acquired the lease, reached the server's /snapshot endpoint, and wrote
# the singleton CR's status.
log "waiting up to ${CACHEINDEX_TIMEOUT}s for cacheindex/cluster-default to be populated"
deadline=$(($(date +%s) + CACHEINDEX_TIMEOUT))
observed=""
until [ -n "$observed" ]; do
observed="$(kubectl get cacheindex cluster-default \
-o jsonpath='{.status.observedServer}' 2>/dev/null || true)"
if [ -n "$observed" ]; then break; fi
if [ "$(date +%s)" -ge "$deadline" ]; then
kubectl get cacheindex cluster-default -o yaml || true
fail "cacheindex/cluster-default.status.observedServer was empty after ${CACHEINDEX_TIMEOUT}s"
fi
sleep 3
done
log "cacheindex/cluster-default.status.observedServer=$observed"
# The CacheIndex table is the operator-facing view for the status poller. Check
# the installed CRD renders the Prefixes/Changed columns and that their JSONPath
# targets actually populate cells in the row instead of falling back to a
# header-only/default NAME/AGE table.
ci_table="$(kubectl get ci cluster-default 2>/dev/null || true)"
ci_header="$(printf '%s\n' "$ci_table" | sed -n '1p')"
ci_row="$(printf '%s\n' "$ci_table" | sed -n '2p')"
for column in PREFIXES CHANGED; do
if ! grep -Eq "(^|[[:space:]])${column}([[:space:]]|$)" <<<"$ci_header"; then
echo "$ci_table"
fail "expected CacheIndex printer column ${column} in kubectl get output"
fi
done
if ! grep -Fq "cluster-default" <<<"$ci_row"; then
echo "$ci_table"
fail "expected CacheIndex printer row to include cluster-default"
fi
ci_prefixes="$(awk 'NR==2 {print $2}' <<<"$ci_table")"
ci_changed="$(awk 'NR==2 {print $3}' <<<"$ci_table")"
if [ "$ci_prefixes" != "0" ]; then
echo "$ci_table"
fail "expected CacheIndex printer column Prefixes=0 in kubectl get output, got: ${ci_prefixes:-<empty>}"
fi
if [ -z "$ci_changed" ] || [ "$ci_changed" = "<none>" ] || [ "$ci_changed" = "<unknown>" ]; then
echo "$ci_table"
fail "expected CacheIndex printer column Changed to be populated in kubectl get output"
fi
log "CacheIndex printer columns render Prefixes=$ci_prefixes Changed=$ci_changed"
# --- CacheIndex CRD per-tenant-memory deprecation assertion ----------------
# Per-tenant memory cannot be honestly attributed on a shared, tenant-unaware
# engine (status.tenants[].memoryUsed double-counts the same bytes once per
# tenant), so the field is DEPRECATED and always 0 — but retained in the
# v1alpha1 schema for wire/shape compatibility (removal deferred to v1beta1).
# The honest per-replica engine total stays. Probing the live CRD proves both
# fields are in the installed bundle, not just the repo.
ci_field_type() {
kubectl get crd cacheindices.inferencecache.io \
-o "jsonpath={.spec.versions[?(@.name=='v1alpha1')].schema.openAPIV3Schema.properties.$1.type}" \
2>/dev/null || true
}
if [ -z "$(ci_field_type 'status.properties.tenants.items.properties.memoryUsed')" ]; then
fail "CRD is missing status.tenants[].memoryUsed (deprecated+zeroed but retained in the v1alpha1 schema for compat — must remain until v1beta1)"
fi
if [ -z "$(ci_field_type 'status.properties.replicas.items.properties.cacheMemoryBytes')" ]; then
fail "CRD is missing status.replicas[].cacheMemoryBytes (the honest per-replica engine total)"
fi
log "CacheIndex CRD serves deprecated status.tenants[].memoryUsed (retained, always 0) and the honest status.replicas[].cacheMemoryBytes"
# --- CacheIndex harmonized-pointer status fields ---------------------------
# hitRate (status.replicas[] and status.tenants[]) and status.tenants[].indexEntries
# use the "nil = not yet reported / computed" pointer convention, aligned with
# the per-instance CacheBackend/CacheTenant surfaces. Pointer-ness itself is NOT
# visible in the OpenAPI schema (a *string still serves as type: string, a
# *int64 as type: integer), so this check only proves the fields still exist
# with their expected scalar leaf types in the installed bundle — the guard is
# against an accidental field drop/rename or a codegen change that alters the
# served type. The value-level nil-vs-observed-0 behavior is exercised by the
# envtest suite (persisted-shape assertions), not here.
if [ "$(ci_field_type 'status.properties.replicas.items.properties.hitRate')" != "string" ]; then
fail "CacheIndex CRD status.replicas[].hitRate is not served as type string"
fi
if [ "$(ci_field_type 'status.properties.tenants.items.properties.hitRate')" != "string" ]; then
fail "CacheIndex CRD status.tenants[].hitRate is not served as type string"
fi
if [ "$(ci_field_type 'status.properties.tenants.items.properties.indexEntries')" != "integer" ]; then
fail "CacheIndex CRD status.tenants[].indexEntries is not served as type integer"
fi
log "CacheIndex CRD serves status.{replicas,tenants}[].hitRate (string) + status.tenants[].indexEntries (integer)"
# --- CachePolicy push + printer-column setup --------------------------------
# Apply a CachePolicy in a dedicated namespace and verify its operator-facing
# table columns render. The gRPC side-effect assertion below proves this CR
# was pushed through the controller's authenticated /policy bridge and adopted
# by the server; keeping the apply here gives the watch-triggered reconcile
# time to run before the port-forward opens.
log "resetting CachePolicy smoke namespace $POLICY_SMOKE_NS"
kubectl delete namespace "$POLICY_SMOKE_NS" --ignore-not-found --wait=true --timeout=60s >/dev/null \
|| fail "timed out waiting for prior CachePolicy smoke namespace $POLICY_SMOKE_NS to delete"
log "applying CachePolicy sample in namespace $POLICY_SMOKE_NS"
kubectl create namespace "$POLICY_SMOKE_NS" --dry-run=client -o yaml \
| kubectl apply -f - >/dev/null
kubectl -n "$POLICY_SMOKE_NS" apply -f config/samples/cache_v1alpha1_cachepolicy.yaml >/dev/null
# The Eviction printer column is the operator-facing surface kept on the
# CachePolicy CRD. Verify the header AND the row value — the sample
# intentionally omits spec.eviction so this also exercises the
# +kubebuilder:default=LRU marker (the default must fill the column).
cp_table="$(kubectl -n "$POLICY_SMOKE_NS" get cachepolicy cachepolicy-sample 2>/dev/null || true)"
cp_header="$(printf '%s\n' "$cp_table" | sed -n '1p')"
if ! grep -Eq "(^|[[:space:]])EVICTION([[:space:]]|$)" <<<"$cp_header"; then
echo "$cp_table"
fail "expected CachePolicy printer column EVICTION in kubectl get output"
fi
cp_eviction="$(kubectl -n "$POLICY_SMOKE_NS" get cachepolicy cachepolicy-sample \
-o jsonpath='{.spec.eviction}' 2>/dev/null || true)"
if [ "$cp_eviction" != "LRU" ]; then
echo "$cp_table"
fail "expected .spec.eviction=LRU after the kubebuilder default fired; got '$cp_eviction'"
fi
if ! grep -Fq "cachepolicy-sample" <<<"$cp_table" || \
! grep -Fq "LRU" <<<"$cp_table"; then
echo "$cp_table"
fail "expected CachePolicy printer row to include name and Eviction=LRU"
fi
log "CachePolicy default eviction=LRU stamped, printer column renders Eviction"
# --- CachePolicy admission rejection (one-per-namespace webhook) ------------
# The installed validating webhook — served through the bundle's webhook
# Service with the cert-manager-injected CA bundle — must reject a SECOND
# CachePolicy in the namespace. Proving it on the real install (not just
# envtest) exercises the Service + cert + CA-injection path an operator's
# `kubectl apply` actually traverses: a broken cainjection annotation, a wrong
# Service selector, or a missing cert would fail here while envtest still
# passed. cachepolicy-sample already occupies $POLICY_SMOKE_NS, so this apply
# must be denied.
log "asserting a second CachePolicy in $POLICY_SMOKE_NS is rejected at admission"
second_cp_yaml="$(cat <<EOF
apiVersion: inferencecache.io/v1alpha1
kind: CachePolicy
metadata:
name: cachepolicy-sample-2
namespace: $POLICY_SMOKE_NS
spec: {}
EOF
)"
if cp_reject_out="$(printf '%s\n' "$second_cp_yaml" | kubectl apply -f - 2>&1)"; then
echo "$cp_reject_out"
fail "second CachePolicy in $POLICY_SMOKE_NS was admitted; the one-per-namespace webhook did not fire on the real install"
fi
if ! grep -q "already has CachePolicy" <<<"$cp_reject_out"; then
echo "$cp_reject_out"
fail "second CachePolicy was rejected, but not by the expected webhook rule (missing 'already has CachePolicy' message)"
fi
log "second CachePolicy rejected at admission by the installed validating webhook"
# --- CacheTenant status projection assertion -------------------------------
# Apply a CacheTenant and prove the poller's per-tenant projection writes its
# status. The smoke cluster has no engine pods, so the tenant holds zero
# prefixes: the projection must report indexEntries=0 (observed-zero, not nil)
# with Ready=True. This exercises the CacheTenant CRD schema, the combined
# CachePolicy+CacheTenant push to /policy, and the per-tenant status writer.
log "applying CacheTenant sample and waiting for its status projection"
kubectl apply -f config/samples/cache_v1alpha1_cachetenant.yaml
deadline=$(($(date +%s) + CACHEINDEX_TIMEOUT))
ct_entries=""
until [ -n "$ct_entries" ]; do
ct_entries="$(kubectl get cachetenant cachetenant-sample \
-o jsonpath='{.status.indexEntries}' 2>/dev/null || true)"
if [ -n "$ct_entries" ]; then break; fi
if [ "$(date +%s)" -ge "$deadline" ]; then
kubectl get cachetenant cachetenant-sample -o yaml || true
fail "cachetenant-sample.status.indexEntries was empty after ${CACHEINDEX_TIMEOUT}s"
fi
sleep 3
done
if [ "$ct_entries" != "0" ]; then
kubectl get cachetenant cachetenant-sample -o yaml || true
fail "expected cachetenant-sample.status.indexEntries=0 (no traffic), got: $ct_entries"
fi
ct_ready="$(kubectl get cachetenant cachetenant-sample \
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)"
if [ "$ct_ready" != "True" ]; then
kubectl get cachetenant cachetenant-sample -o yaml || true
fail "expected cachetenant-sample Ready=True, got: ${ct_ready:-<unset>}"
fi
# The printer columns (Tenant / Entries / Quota) are themselves an operator-
# facing surface. Verify `kubectl get cachetenants` renders them — a default
# table with only NAME/AGE would mean the additionalPrinterColumns regressed.
ct_table="$(kubectl get cachetenant cachetenant-sample 2>/dev/null || true)"
if ! grep -q 'tenant-a' <<<"$ct_table" || ! grep -q '100000' <<<"$ct_table"; then
echo "$ct_table"
fail "expected CacheTenant printer columns (Tenant=tenant-a, Quota=100000) in kubectl get output"
fi
log "cachetenant-sample.status: indexEntries=$ct_entries Ready=$ct_ready (printer columns OK)"
# --- CacheTenant admission rejection (tenantID-uniqueness webhook) ----------
# The installed validating webhook must reject a SECOND CacheTenant claiming an
# already-used tenantID in the same namespace. cachetenant-sample (tenantID
# tenant-a) was applied to the default namespace above, so a second tenant
# reusing tenant-a there must be denied — proving the in-cluster
# Service/cert/CA-injection path for this webhook too.
log "asserting a duplicate-tenantID CacheTenant in the default namespace is rejected at admission"
second_ct_yaml="$(cat <<'EOF'
apiVersion: inferencecache.io/v1alpha1
kind: CacheTenant
metadata:
name: cachetenant-sample-2
spec:
tenantID: tenant-a