-
Notifications
You must be signed in to change notification settings - Fork 11
493 lines (463 loc) · 24.7 KB
/
Copy pathintegration-tests.yml
File metadata and controls
493 lines (463 loc) · 24.7 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
name: Integration Tests
on:
# Every branch, not just master: this was master-only from 2026-03 to 2026-08, during which
# the branch accumulated 50 commits of chart changes with no integration run behind them.
# branches: ['**'] rather than a bare `push:` so release tag pushes do not run this workflow
# a second time on top of the branch push that produced them.
#
# No `pull_request:` trigger — every PR branch lives in this repo, so the push event already
# covers it and adding one would double the k3d clusters per commit.
push:
branches: ['**']
workflow_dispatch:
# Integration runs are far more expensive than the unit suite, so superseded runs on the same
# branch are cancelled rather than left to finish.
concurrency:
group: integration-tests-${{ github.ref }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
integration-tests:
name: Integration Tests (${{ matrix.mode.name }})
runs-on: ubuntu-latest
# Without this the job inherits GitHub's 6-hour default. Observed 2026-08-13: a runner whose
# k3d cluster stopped answering kubectl left a job wedged in a log-collection step that ignored
# cancel, force-cancel AND concurrency eviction — all of which need a responsive runner — while
# a queued run sat behind it and a chart stayed half-released. A timeout is the only bound that
# does not depend on the runner cooperating. Sized for the worst legitimate case: scaled installs
# for up to 20m (installTimeout) and then runs `helm test` for up to 10m.
timeout-minutes: 40
strategy:
fail-fast: false
# Five jobs starting at once means five k3d clusters pulling the same large Ontoserver image
# simultaneously, from the shared IP range GitHub-hosted runners use — which registries rate
# limit against. Staggering trades wall-clock for a much smaller burst. Suspected (not proven)
# cause of the 2026-08-13 install stalls; the diagnostics added alongside this will say.
max-parallel: 2
matrix:
mode:
- name: read-only
release: ontoserver-ro
isReadOnly: "true"
expectedHook: ontoserver-ro-ontoserver-test-fhir-ro
unexpectedHook: ontoserver-ro-ontoserver-test-fhir-rw
runHelmTest: true
verifyTraefikRoute: false
valuesFile: ""
needsPostgres: false
needsTraefikCrds: false
needsEnvoyGateway: false
verifyGatewayRoute: false
installTimeout: 10m
- name: read-write
release: ontoserver-rw
isReadOnly: "false"
expectedHook: ontoserver-rw-ontoserver-test-fhir-rw
unexpectedHook: ontoserver-rw-ontoserver-test-fhir-ro
runHelmTest: true
verifyTraefikRoute: false
valuesFile: ""
needsPostgres: false
needsTraefikCrds: false
needsEnvoyGateway: false
verifyGatewayRoute: false
installTimeout: 10m
- name: traefik-https-backend
release: ontoserver-traefik
isReadOnly: "true"
expectedHook: ""
unexpectedHook: ""
runHelmTest: false
verifyTraefikRoute: true
valuesFile: traefik-https-backend-values.yaml
needsPostgres: false
needsTraefikCrds: true
needsEnvoyGateway: false
verifyGatewayRoute: false
installTimeout: 10m
# Gateway API: gateway.yaml and envoy-gateway-policies.yaml were unit-tested only, so
# nothing had ever checked that a real API server accepts them. The Envoy Gateway CRDs
# reject a listener whose protocol and tls block disagree, an HTTPRoute whose
# sectionName matches no listener, or an unknown policy field — none of which
# helm-unittest can see.
#
# Single-instance: this covers the main HTTPRoute plus all three traffic policies, and
# doubles as the negative case for the $closure HTTPRoute, which must render only for
# a scaled StatefulSet.
- name: gateway
release: ontoserver-gw
isReadOnly: "true"
expectedHook: ""
unexpectedHook: ""
runHelmTest: false
verifyTraefikRoute: false
valuesFile: gateway-values.yaml
needsPostgres: false
needsTraefikCrds: false
needsEnvoyGateway: true
verifyGatewayRoute: true
installTimeout: 10m
# Scaled StatefulSet: the only mode that exercises test-closure-scaled-job.yaml,
# i.e. that $closure is pinned to pod-0 and both pods are individually addressable.
# Needs an external PostgreSQL (a scaled deployment cannot use the sidecar DB) and
# its fixture enables a Traefik IngressRoute, hence needsTraefikCrds.
#
# Read-only is mandatory when scaled (enforced by validate-values.yaml): each replica
# has its own Lucene index, so a write served by one replica is missing from the
# others. $closure stays available because it is pinned to pod-0.
- name: scaled
release: ontoserver-scaled
isReadOnly: "true"
expectedHook: ontoserver-scaled-ontoserver-test-closure-scaled
unexpectedHook: ontoserver-scaled-ontoserver-test-fhir-rw
runHelmTest: true
verifyTraefikRoute: false
valuesFile: scaled-values.yaml
needsPostgres: true
needsTraefikCrds: true
needsEnvoyGateway: false
verifyGatewayRoute: false
# Two Ontoserver pods start in parallel but share one runner's CPU.
installTimeout: 20m
steps:
- uses: actions/checkout@v5
- name: Install Helm
uses: azure/setup-helm@v5.0.1
with:
version: v3.18.4
- name: Create k3d cluster
uses: AbsaOSS/k3d-action@v2
with:
cluster-name: ontoserver-test
k3d-version: v5.8.3
# Single node (server only, which k3s leaves schedulable). A second node bought no
# coverage — the chart declares no podAntiAffinity or topologySpreadConstraints, so
# nothing depends on placement, and even scaled's two replicas may co-locate. It did
# cost a second copy of every pulled image on the runner's limited disk.
args: >-
--agents 0
--no-lb
--image rancher/k3s:v1.32.10-k3s1
- name: Wait for Traefik CRDs
if: matrix.mode.needsTraefikCrds
run: |
until kubectl get crd ingressroutes.traefik.io >/dev/null 2>&1; do sleep 2; done
until kubectl wait --for=condition=established crd/ingressroutes.traefik.io --timeout=10s 2>/dev/null; do sleep 2; done
# A scaled deployment must share one database across replicas, so the sidecar DB is
# disabled in scaled-values.yaml and this stands in for the external PostgreSQL that
# ontoserver.config.spring.datasource.url points at.
- name: Deploy PostgreSQL
if: matrix.mode.needsPostgres
run: |
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16
env:
- name: POSTGRES_DB
value: ontoserver
- name: POSTGRES_USER
value: ontoserver
- name: POSTGRES_PASSWORD
value: ontoserver
ports:
- containerPort: 5432
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
memory: 1G
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
EOF
kubectl rollout status deployment/postgres --timeout=3m
# Envoy Gateway's install.yaml bundles the upstream Gateway API CRDs, so this single apply
# provides both the Gateway/HTTPRoute/GatewayClass schemas and the gateway.envoyproxy.io
# policy CRDs plus the controller that programs them. Server-side apply is required: the
# CRDs exceed the 256 KiB annotation limit that client-side apply would need for
# last-applied-configuration.
#
# Pinned rather than tracking latest, like the k3s, k3d and Helm versions above — an
# upstream release should never be able to fail a run that contains no chart change.
#
# --force-conflicts is required, not incidental: k3s installs its own copy of the Gateway
# API CRDs through the bundled Traefik HelmChart, so the apply collides with field manager
# "helm" on .spec.versions and the bundle-version/channel annotations. Without it the
# cluster keeps Traefik's CRD version and Envoy Gateway may be programming against a
# schema it was not built for. Taking ownership pins the CRDs to the version this pinned
# controller ships with. (Traefik's own Gateway API support is unused here — the chart's
# GatewayClass names the Envoy controller, so only Envoy Gateway claims it.)
- name: Install Envoy Gateway
if: matrix.mode.needsEnvoyGateway
run: |
kubectl apply --server-side --force-conflicts -f https://github.com/envoyproxy/gateway/releases/download/v1.8.3/install.yaml
kubectl wait --for=condition=Available --timeout=5m \
-n envoy-gateway-system deployment/envoy-gateway
# The GatewayClass the chart creates references gateway.envoyproxy.io CRDs; wait for
# them to be established so the install below cannot lose a race with the CRD rollout.
for crd in gateways.gateway.networking.k8s.io httproutes.gateway.networking.k8s.io \
clienttrafficpolicies.gateway.envoyproxy.io \
backendtrafficpolicies.gateway.envoyproxy.io \
securitypolicies.gateway.envoyproxy.io \
envoyproxies.gateway.envoyproxy.io; do
kubectl wait --for=condition=established --timeout=2m "crd/$crd"
done
- name: Build chart dependencies
run: helm dependency build ./charts/ontoserver
- name: Install chart
env:
QUAY_USERNAME: ${{ secrets.QUAY_USERNAME }}
QUAY_PASSWORD: ${{ secrets.QUAY_PASSWORD }}
run: |
# An array rather than a string: the scaled mode's --set values contain escaped dots
# that word-splitting an unquoted variable would mangle.
EXTRA=()
if [[ -n "${{ matrix.mode.valuesFile }}" ]]; then
EXTRA+=(--values "./charts/ontoserver/tests/fixtures/${{ matrix.mode.valuesFile }}")
fi
if [[ "${{ matrix.mode.needsPostgres }}" == "true" ]]; then
# Password is supplied here rather than in the fixture so the fixture stays
# credential-free; matches the local k3d recipe documented in scaled-values.yaml.
EXTRA+=(--set 'ontoserver.secretConfig.spring\.datasource\.password=ontoserver')
# StatefulSet volumeClaimTemplates need a real storage class; k3s ships local-path.
EXTRA+=(--set ontoserver.deployment.persistence.files.storageClass.provided.enabled=false)
EXTRA+=(--set ontoserver.deployment.persistence.files.storageClass.name=local-path)
fi
helm install ${{ matrix.mode.release }} ./charts/ontoserver \
"${EXTRA[@]}" \
--set ontoserver.deployment.isReadOnly=${{ matrix.mode.isReadOnly }} \
--set ontoserver.managementService.enabled=true \
--set ontoserver.imageCredentials.username="$QUAY_USERNAME" \
--set ontoserver.imageCredentials.password="$QUAY_PASSWORD" \
--set ontoserver.resources.ontoserver.requests.cpu=500m \
--set ontoserver.resources.ontoserver.limits.cpu=2 \
--set ontoserver.resources.ontoserver.requests.memory=2G \
--set ontoserver.resources.ontoserver.limits.memory=2G \
--set ontoserver.resources.ontoserver.initialHeapSize=1500m \
--set ontoserver.resources.ontoserver.maxHeapSize=1500m \
--set ontoserver.resources.db.requests.cpu=250m \
--set ontoserver.resources.db.limits.cpu=1 \
--set ontoserver.resources.db.requests.memory=512Mi \
--set ontoserver.resources.db.limits.memory=1G \
--wait \
--timeout ${{ matrix.mode.installTimeout }}
# `cancelled()` as well as `failure()`: a cancelled run still gets a grace window in which
# always()/cancelled() steps execute, and twice on 2026-08-13 a stalled install was cancelled
# by hand — which is not a failure, so this step did not run and the evidence was lost.
# Every kubectl carries --request-timeout so a wedged API server cannot hang the step that
# exists to explain the wedge, and the step itself is bounded.
- name: Diagnose failed install
if: failure() || cancelled()
timeout-minutes: 3
run: |
echo "=== Node conditions (DiskPressure / MemoryPressure) ==="
kubectl --request-timeout=30s describe node | sed -n '/Conditions:/,/Addresses:/p' || true
echo "=== Runner disk ==="
df -h / || true
docker system df || true
echo "=== Pod status ==="
kubectl --request-timeout=30s get pods -o wide || true
echo "=== Pod description ==="
kubectl --request-timeout=30s describe pods || true
echo "=== Pod logs ==="
kubectl --request-timeout=30s logs -l app=${{ matrix.mode.release }}-ontoserver --all-containers=true --tail=100 || true
echo "=== Events ==="
kubectl --request-timeout=30s get events --sort-by='.lastTimestamp' || true
echo "=== Image pull / eviction events ==="
kubectl --request-timeout=30s get events -o wide \
| grep -Ei 'pull|evict|diskpressure|imagegc|429|throttl' || true
- name: Run integration tests
if: matrix.mode.runHelmTest
run: helm test ${{ matrix.mode.release }} --timeout 10m
- name: Verify mode-specific test hook rendered
if: matrix.mode.runHelmTest
run: |
kubectl get job ${{ matrix.mode.expectedHook }}
if kubectl get job ${{ matrix.mode.unexpectedHook }} >/dev/null 2>&1; then
echo "Unexpected Helm test job found: ${{ matrix.mode.unexpectedHook }}"
exit 1
fi
- name: Verify Traefik route (HTTPS backend)
if: matrix.mode.verifyTraefikRoute
run: |
# Expose Traefik's web entrypoint locally (ports.web.exposedPort=80)
kubectl port-forward -n kube-system svc/traefik 18080:80 &
# Wait for port-forward to be ready (up to 15 s).
# Uses Traefik's built-in /ping health endpoint with --fail so the loop
# only breaks on an actual HTTP 200, not on connection errors returning 0.
for i in $(seq 15); do
curl -sf http://localhost:18080/ping >/dev/null 2>&1 && break
sleep 1
done
# Full path: curl → Traefik (web entrypoint) → ServersTransport (insecureSkipVerify)
# → Ontoserver HTTPS (self-signed /keystore.p12)
curl -sf \
-H "Host: ontoserver.traefik-test.local" \
http://localhost:18080/fhir/metadata \
| grep '"resourceType":"CapabilityStatement"'
- name: Verify Gateway API route (Envoy Gateway)
if: matrix.mode.verifyGatewayRoute
run: |
set -o pipefail
GW=${{ matrix.mode.release }}-gw
# Accepted, plus the per-listener conditions below — deliberately NOT the Gateway-level
# Programmed condition. Envoy Gateway exposes the proxy through a LoadBalancer Service,
# and this cluster is created with --no-lb, so no address is ever assigned and the
# Gateway sits at Programmed=False/AddressNotAssigned forever even when everything the
# chart renders is correct. That condition reports on the cluster's load balancer, not
# on the chart.
kubectl wait --for=condition=Accepted --timeout=5m gateway/"$GW"
# The per-listener conditions are the real assertion a CRDs-only install cannot make:
# Programmed here means the controller translated this listener and pushed it to the
# data plane. A protocol/tls mismatch or a bad certificateRef fails at this level, and
# attachedRoutes proves the HTTPRoute's sectionName actually bound to the listener
# rather than silently matching nothing.
for i in $(seq 45); do
lprog=$(kubectl get gateway "$GW" \
-o jsonpath='{.status.listeners[0].conditions[?(@.type=="Programmed")].status}' 2>/dev/null || true)
lres=$(kubectl get gateway "$GW" \
-o jsonpath='{.status.listeners[0].conditions[?(@.type=="ResolvedRefs")].status}' 2>/dev/null || true)
attached=$(kubectl get gateway "$GW" \
-o jsonpath='{.status.listeners[0].attachedRoutes}' 2>/dev/null || true)
[[ "$lprog" == "True" && "$lres" == "True" && "${attached:-0}" -ge 1 ]] && break
sleep 4
done
echo "listener: Programmed=$lprog ResolvedRefs=$lres attachedRoutes=$attached"
[[ "$lprog" == "True" && "$lres" == "True" && "${attached:-0}" -ge 1 ]] \
|| { kubectl get gateway "$GW" -o yaml; exit 1; }
# HTTPRoute conditions live under status.parents[], per parent Gateway — there is no
# top-level status.conditions, so `kubectl wait --for=condition=Accepted` would hang
# on a route that is already fine. Poll the nested condition instead.
for route in ${{ matrix.mode.release }}-route; do
for i in $(seq 30); do
accepted=$(kubectl get httproute "$route" \
-o jsonpath='{.status.parents[0].conditions[?(@.type=="Accepted")].status}' 2>/dev/null || true)
resolved=$(kubectl get httproute "$route" \
-o jsonpath='{.status.parents[0].conditions[?(@.type=="ResolvedRefs")].status}' 2>/dev/null || true)
[[ "$accepted" == "True" && "$resolved" == "True" ]] && break
sleep 4
done
echo "$route: Accepted=$accepted ResolvedRefs=$resolved"
# ResolvedRefs matters as much as Accepted: it is what fails when backendRefs names
# a Service that does not exist, which is exactly what backendServiceNameOverride
# and the pod0-service default get wrong.
[[ "$accepted" == "True" && "$resolved" == "True" ]] || { kubectl get httproute "$route" -o yaml; exit 1; }
done
# This mode is single-instance, so the $closure route must NOT exist — it renders only
# for a scaled StatefulSet. Guards against the condition being widened by accident.
if kubectl get httproute ${{ matrix.mode.release }}-closure-route >/dev/null 2>&1; then
echo "Unexpected \$closure HTTPRoute on a single-instance deployment"
exit 1
fi
# All three policies must be accepted by the controller, not merely admitted by the
# API server. These use gateway.envoyproxy.io/v1alpha1 and had no coverage at all.
for kind in clienttrafficpolicy/${{ matrix.mode.release }}-client-settings \
backendtrafficpolicy/${{ matrix.mode.release }}-backend-settings \
securitypolicy/${{ matrix.mode.release }}-ip-filter; do
for i in $(seq 30); do
status=$(kubectl get "$kind" \
-o jsonpath='{.status.ancestors[0].conditions[?(@.type=="Accepted")].status}' 2>/dev/null || true)
[[ "$status" == "True" ]] && break
sleep 4
done
echo "$kind: Accepted=$status"
[[ "$status" == "True" ]] || { kubectl get "$kind" -o yaml; exit 1; }
done
# End-to-end, mirroring the Traefik mode: the Envoy fleet is a separate Deployment the
# controller creates in its own namespace, discovered by owning-gateway label rather
# than by a generated name (which carries a hash).
ENVOY_SVC=$(kubectl get svc -n envoy-gateway-system \
-l gateway.envoyproxy.io/owning-gateway-name="$GW" \
-o jsonpath='{.items[0].metadata.name}')
echo "Envoy service: $ENVOY_SVC"
kubectl wait --for=condition=Available --timeout=3m -n envoy-gateway-system \
deployment -l gateway.envoyproxy.io/owning-gateway-name="$GW"
kubectl port-forward -n envoy-gateway-system svc/"$ENVOY_SVC" 18081:80 &
for i in $(seq 30); do
curl -s -o /dev/null -H "Host: ontoserver.gateway-test.local" \
http://localhost:18081/fhir/metadata && break
sleep 2
done
# Full path: curl → Envoy (HTTP listener :80, hostname-matched) → HTTPRoute
# → gw-ontoserver-service → Ontoserver
curl -sf \
-H "Host: ontoserver.gateway-test.local" \
http://localhost:18081/fhir/metadata \
| grep '"resourceType":"CapabilityStatement"'
# always() with no timeout is what wedged for 8 minutes on 2026-08-13 and became
# uncancellable. Every collection step is bounded, and every kubectl gets a request timeout.
- name: Collect test logs
if: always()
timeout-minutes: 3
run: |
echo "=== Events ==="
kubectl --request-timeout=30s get events --sort-by='.lastTimestamp'
echo "=== Ontoserver pod logs (all containers) ==="
kubectl --request-timeout=30s logs -l app=${{ matrix.mode.release }}-ontoserver --all-containers=true --tail=50 || true
echo "=== test-metadata job ==="
kubectl --request-timeout=30s describe job ${{ matrix.mode.release }}-ontoserver-test-metadata || true
echo "=== test-metadata logs ==="
kubectl --request-timeout=30s logs -l job-name=${{ matrix.mode.release }}-ontoserver-test-metadata --tail=200 || true
# Every test hook, not just the mode-specific one: `helm test` fails on ANY hook, so
# collecting only expectedHook leaves the actual failure invisible in the log.
- name: Collect helm test hook logs
if: always() && matrix.mode.runHelmTest
timeout-minutes: 3
run: |
for job in $(kubectl --request-timeout=30s get jobs -o name | sed 's|job.batch/||' | grep -- '-test-'); do
echo "=== job/$job ==="
kubectl --request-timeout=30s get job "$job" -o jsonpath='{.status}' || true
echo ""
echo "--- logs ---"
kubectl --request-timeout=30s logs -l job-name="$job" --tail=200 || true
done
- name: Collect Traefik logs
if: always() && matrix.mode.verifyTraefikRoute
timeout-minutes: 3
run: |
echo "=== Traefik logs ==="
kubectl --request-timeout=30s logs -n kube-system -l app.kubernetes.io/name=traefik --tail=50 || true
- name: Collect Gateway API diagnostics
if: always() && matrix.mode.verifyGatewayRoute
timeout-minutes: 3
run: |
echo "=== GatewayClass / Gateway / HTTPRoute ==="
kubectl --request-timeout=30s get gatewayclass,gateway,httproute -o wide || true
echo "=== Gateway status ==="
kubectl --request-timeout=30s get gateway ${{ matrix.mode.release }}-gw -o yaml || true
echo "=== HTTPRoute status ==="
kubectl --request-timeout=30s get httproute -o yaml || true
echo "=== Envoy Gateway policies ==="
kubectl --request-timeout=30s get clienttrafficpolicy,backendtrafficpolicy,securitypolicy -o yaml || true
echo "=== Envoy Gateway control plane logs ==="
kubectl --request-timeout=30s logs -n envoy-gateway-system deployment/envoy-gateway --tail=100 || true
echo "=== Envoy proxy fleet ==="
kubectl --request-timeout=30s get pods,svc -n envoy-gateway-system -o wide || true
kubectl --request-timeout=30s logs -n envoy-gateway-system \
-l gateway.envoyproxy.io/owning-gateway-name=${{ matrix.mode.release }}-gw \
--tail=100 || true