Repository navigation
Expand file tree
/
Copy pathvalidate.py
More file actions
1421 lines (1227 loc) · 54.8 KB
/
Copy pathvalidate.py
File metadata and controls
1421 lines (1227 loc) · 54.8 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
"""Gameplane module preflight validator.
Checks every modules/<name>/template.yaml against the ACTUAL OCI image
config of the image(s) it declares (the top-level `spec.image` fallback
plus every `spec.versions[].image` entry), and fails the build on bug
classes that have shipped broken before. Four modules were once authored
and merged without ever being launched against a real cluster; each broke
for a reason that is statically visible in the image's own OCI config:
1. ARK: Survival Ascended — image has no ENTRYPOINT and its CMD is
`/bin/bash`. With no TTY that's a non-interactive shell that reads
EOF on stdin and exits 0 instantly; the pod restart-loops forever
with an empty log. Fix: `spec.command` must be set whenever an
image relies on its default CMD being a bare interactive shell.
2. Project Zomboid — the image is rootless (uid 10000, not the 1000
its own README claimed). Running it as root, then as the wrong
non-root uid, both failed. Fix: when the image's own `User` is
non-root, `spec.security.runAsUser` must be set to match.
3. Project Zomboid, again — setting `runAsUser` explicitly does not
give the container the `$HOME` it would have gotten from the
image's own `USER` directive. SteamCMD then resolved `$HOME/Steam`
to `//Steam` and died. Fix: whenever the template sets
`runAsUser`, and the image doesn't bake a `HOME` env var, the
template must declare one itself.
4. Garry's Mod / Project Zomboid / 7 Days to Die — `storage.mountPath`
mounted an empty PVC directly over baked-in image content (the
directory holding the game's own entrypoint script), so the
container couldn't even exec. Fix: mountPath must never shadow the
image's own launcher.
Also checked, cheaply and statically: the declared image actually exists
(three modules once shipped with fabricated image references),
`rcon.protocol` is one of the three values the agent implements, and
`capabilities.mods.loaders` is only meaningful alongside a non-empty
`spec.versions` catalog.
## Design notes on severity (why some checks are WARN, not ERROR)
Every rule below started from a literal reading of the bug report above.
Two were deliberately calibrated DOWN after hand-verifying them against
the four already-fixed modules turned up a false positive:
- Rule 3 (`runAsUser` needs `HOME`) fires as ERROR only when the image
shows a SteamCMD-shaped signal (an env var or a WorkingDir/Entrypoint/
Cmd path mentioning "steam"), and WARN otherwise. Verified case: ARK's
own image (mschnitzer/asa-linux-server) sets `runAsUser: 25000` with
no baked HOME and no template HOME override, which a literal reading
would flag — but ARK's `start_server` script (read from the image's
own GitHub source) never references `$HOME` at all; it hardcodes
`STEAM_COMPAT_DATA_PATH`/`STEAM_COMPAT_CLIENT_INSTALL_PATH` instead,
the same substitution SteamCMD-based images use `$HOME` for. Flagging
it as ERROR would fail CI for a module that has shipped and works.
- Rule 4 (mountPath vs. WorkingDir) treats "mountPath equals the image's
bare WorkingDir" as WARN, and reserves ERROR for "mountPath is (or is
an ancestor of) the image's actual Entrypoint/Cmd executable path" —
the literal repro of the historical bug (mounting over the directory
that holds entrypoint.sh). Verified case: ARK's WorkingDir is
`/home/gameserver`, exactly equal to its mountPath — a literal
equality check would flag it — but the image bakes nothing there at
build time (a single KIWI-built layer with no COPY of game files;
everything is fetched by SteamCMD into that empty directory at
runtime), so mounting a PVC there is exactly correct and is the
documented, working design. minecraft-java has the identical shape
(WorkingDir == mountPath == `/data`, also the image's own declared
Volume) and is likewise fine. The real historical bug (PZ's original
mountPath of `/home/steam`, see git history) is caught by the
Entrypoint/Cmd-prefix ERROR check instead, since `/home/steam` is
literally the parent directory of that image's `entrypoint.sh`.
Both calibrations are documented at the point of use below, with the
verification evidence repeated so a future reader doesn't have to
re-derive it.
## Network resilience
Every image lookup goes through `_curl_json`, which never raises on a
network-level failure (DNS, timeout, connection reset) or on a non-2xx
HTTP status — it returns a tagged failure dict instead. A registry that
can't be reached, or a registry this script doesn't know how to
authenticate against (anything but Docker Hub), is reported as a WARN
and skipped, never a hard failure — only a confirmed 404 (repository or
tag genuinely doesn't exist on Docker Hub) is an ERROR. This means a
transient network blip in CI degrades the run to "couldn't verify",
never a spurious red build.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError: # pragma: no cover - CI installs pyyaml explicitly
sys.stderr.write(
"error: PyYAML is required (`pip install pyyaml`) to run validate.py\n"
)
sys.exit(2)
ERROR = "ERROR"
WARN = "WARN"
MANIFEST_ACCEPT = ", ".join(
[
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.oci.image.index.v1+json",
]
)
CURL_TIMEOUT_SECS = 15
# Wire protocols the agent's rcon package actually implements. Keep in sync with
# the GameTemplate CRD's rcon.protocol enum and agent/internal/rcon/. A protocol
# listed here but not implemented lets a module ship a console that never
# connects, so this list is deliberately conservative.
RCON_PROTOCOLS = ("source", "telnet", "websocket", "battleye", "satisfactory", "palworld", "nuclearoption", "rest", "cli", "none")
@dataclass
class Finding:
level: str
rule: str
message: str
def __str__(self) -> str: # pragma: no cover - trivial
return f"{self.level:5s} [{self.rule}] {self.message}"
# --------------------------------------------------------------------------
# Registry access (Docker Hub only; anything else is skipped gracefully)
# --------------------------------------------------------------------------
def _curl(url: str, headers: dict[str, str] | None = None) -> tuple[int | None, bytes]:
"""Run curl and return (http_status_or_None, body_bytes).
http_status is None when curl itself failed (DNS, timeout, connection
reset, TLS error, ...) rather than the server returning a bad status —
callers use that distinction to tell "confirmed missing" apart from
"couldn't tell".
"""
cmd = ["curl", "-s", "-L", "-4", "-m", str(CURL_TIMEOUT_SECS), "-w", "\n%{http_code}"]
for k, v in (headers or {}).items():
cmd += ["-H", f"{k}: {v}"]
cmd += [url]
try:
out = subprocess.run(
cmd, capture_output=True, timeout=CURL_TIMEOUT_SECS + 10, check=False
)
except (subprocess.TimeoutExpired, OSError) as exc:
return None, str(exc).encode()
if out.returncode != 0:
return None, out.stderr or out.stdout
body = out.stdout
idx = body.rfind(b"\n")
if idx == -1:
return None, body
status_bytes = body[idx + 1 :]
try:
status = int(status_bytes)
except ValueError:
return None, body
return status, body[:idx]
def _curl_json(url: str, headers: dict[str, str] | None = None):
"""Returns (status, parsed_json_or_None, raw_body)."""
status, body = _curl(url, headers)
if status is None:
return None, None, body
if status < 200 or status >= 300:
return status, None, body
try:
return status, json.loads(body), body
except json.JSONDecodeError:
return status, None, body
def _curl_headers(url: str, headers: dict[str, str] | None = None) -> tuple[int | None, dict[str, str]]:
"""Run curl and return (http_status_or_None, response_headers).
The body is discarded; only headers are read. `-L` means redirects can
produce several header blocks, so keys are overwritten as they arrive and
the LAST value for a name wins — that is the one belonging to the
response actually served. Header names are lower-cased (HTTP header
names are case-insensitive and registries disagree on casing).
"""
cmd = [
"curl", "-s", "-L", "-4", "-m", str(CURL_TIMEOUT_SECS),
"-o", os.devnull, "-D", "-", "-w", "\n%{http_code}",
]
for k, v in (headers or {}).items():
cmd += ["-H", f"{k}: {v}"]
cmd += [url]
try:
out = subprocess.run(
cmd, capture_output=True, timeout=CURL_TIMEOUT_SECS + 10, check=False
)
except (subprocess.TimeoutExpired, OSError):
return None, {}
if out.returncode != 0:
return None, {}
text = out.stdout.decode("utf-8", "replace")
idx = text.rfind("\n")
if idx == -1:
return None, {}
try:
status = int(text[idx + 1 :])
except ValueError:
return None, {}
parsed: dict[str, str] = {}
for line in text[:idx].splitlines():
if ":" not in line:
continue
name, _, value = line.partition(":")
parsed[name.strip().lower()] = value.strip()
return status, parsed
def _parse_auth_challenge(header: str) -> dict[str, str]:
"""Pull the key="value" pairs out of a `WWW-Authenticate: Bearer …` header."""
return dict(re.findall(r'(\w+)="([^"]*)"', header))
def _registry_token(registry_host: str | None, repo: str) -> tuple[str | None, str, dict | None]:
"""Get a pull token for repo, returning (token, api_base, error_or_None).
Docker Hub keeps its known-good hardcoded flow. Every other registry is
resolved through the standard OCI bearer challenge: request /v2/, read
the realm/service out of the 401's WWW-Authenticate header, and exchange
them for an anonymous pull token. That is not Docker-Hub-specific — it is
how the distribution spec says a client discovers auth — and it is what
lets the one module on a self-hosted registry (dayz, on a GitLab
container registry) be checked and pinned like every other.
A registry that requires real credentials returns no token; the caller
reports that as a skip, not a failure, since the validator has no
business holding registry credentials.
"""
if registry_host is None:
api_base = "https://registry-1.docker.io"
token_url = (
f"https://auth.docker.io/token?service=registry.docker.io"
f"&scope=repository:{repo}:pull"
)
else:
api_base = f"https://{registry_host}"
status, headers = _curl_headers(f"{api_base}/v2/")
if status is None:
return None, api_base, {
"ok": False, "skipped": False, "network_error": True,
"reason": f"could not reach registry {registry_host!r}",
}
# An open registry answers /v2/ with 200 and needs no token at all.
if status == 200:
return "", api_base, None
challenge = headers.get("www-authenticate", "")
if not challenge.lower().startswith("bearer"):
return None, api_base, {
"ok": False, "skipped": True, "network_error": False,
"reason": (
f"registry {registry_host!r} answered /v2/ with HTTP {status} and no "
"bearer challenge — it needs credentials this validator does not hold; "
"skipping (not a failure)"
),
}
parts = _parse_auth_challenge(challenge)
realm = parts.get("realm")
if not realm:
return None, api_base, {
"ok": False, "skipped": True, "network_error": False,
"reason": f"registry {registry_host!r} sent a bearer challenge with no realm; skipping",
}
token_url = f"{realm}?service={parts.get('service', '')}&scope=repository:{repo}:pull"
status, body, raw = _curl_json(token_url)
if status is None:
return None, api_base, {
"ok": False, "skipped": False, "network_error": True,
"reason": f"could not reach the token endpoint ({raw[:200]!r})",
}
if status != 200 or not body:
return None, api_base, {
"ok": False, "skipped": False, "network_error": True,
"reason": f"token endpoint returned HTTP {status}",
}
# Docker Hub returns `token`; the OCI spec also allows `access_token`,
# which is what GitLab's registry returns.
token = body.get("token") or body.get("access_token")
if not token:
return None, api_base, {
"ok": False, "skipped": True, "network_error": False,
"reason": (
f"registry issued no anonymous pull token for {repo!r} — it likely "
"requires credentials; skipping (not a failure)"
),
}
return token, api_base, None
def resolve_digest(image_ref: str) -> dict[str, Any]:
"""Resolve an image reference to the digest its tag currently points at.
Return shape mirrors fetch_image_config: {"ok": True, "digest": "sha256:…"}
or {"ok": False, "skipped": bool, "network_error": bool, "reason": str}.
This deliberately returns the digest of the TAG manifest — which for a
multi-arch image is the index (manifest-list) digest, covering every
platform the tag publishes. It must never be confused with the
per-platform digest fetch_image_config drills down to: pinning that
would silently reduce a module to a single architecture and break the
arm64 half of the e2e matrix.
"""
registry_host, repo, reference = _parse_ref(image_ref)
token, api_base, err = _registry_token(registry_host, repo)
if err is not None:
return err
headers = {"Accept": MANIFEST_ACCEPT}
if token:
headers["Authorization"] = f"Bearer {token}"
status, resp_headers = _curl_headers(f"{api_base}/v2/{repo}/manifests/{reference}", headers)
if status is None:
return {
"ok": False, "skipped": False, "network_error": True,
"reason": f"could not reach {api_base} resolving {image_ref!r}",
}
if status == 404:
return {
"ok": False, "skipped": False, "network_error": False,
"reason": f"{image_ref}: HTTP 404 — repository or tag does not exist",
}
if status != 200:
return {
"ok": False, "skipped": False, "network_error": True,
"reason": f"registry returned HTTP {status} resolving {image_ref!r}",
}
digest = resp_headers.get("docker-content-digest")
if not digest:
return {
"ok": False, "skipped": False, "network_error": True,
"reason": (
f"{image_ref}: registry served the manifest without a "
"Docker-Content-Digest header, so there is nothing authoritative to pin to"
),
}
return {"ok": True, "digest": digest}
def _parse_ref(image_ref: str) -> tuple[str | None, str, str]:
"""Split an image reference into (registry_host_or_None, repo, reference).
registry_host is None for Docker Hub (bare `user/repo` or official
`repo`, both normalized to Docker Hub's `library/` namespace). For any
other registry it is the bare host, and `repo` has that host stripped —
that is the form both the /v2/ URL path and the token scope take, so
leaving it on produces 404s on every request.
`reference` is whatever the registry accepts after /manifests/: a tag,
or a digest when the ref is pinned. Modules pin with the readable
`repo:tag@sha256:…` form, where the tag is decorative and the digest
wins; the tag must be dropped here or it stays glued to the repo path.
"""
if "@" in image_ref:
repo_part, tag = image_ref.split("@", 1)
# `repo:tag@sha256:…` — discard the decorative tag, keep the digest.
if ":" in repo_part.rsplit("/", 1)[-1]:
repo_part = repo_part.rsplit(":", 1)[0]
elif ":" in image_ref.rsplit("/", 1)[-1]:
repo_part, tag = image_ref.rsplit(":", 1)
else:
repo_part, tag = image_ref, "latest"
first_segment = repo_part.split("/", 1)[0]
is_custom_registry = "." in first_segment or ":" in first_segment or first_segment == "localhost"
if is_custom_registry:
# A host with no repository path after it is not a usable reference;
# hand it back whole so the caller reports it rather than IndexError.
if "/" not in repo_part:
return first_segment, repo_part, tag
return first_segment, repo_part.split("/", 1)[1], tag
if "/" not in repo_part:
repo_part = f"library/{repo_part}"
return None, repo_part, tag
def fetch_image_config(image_ref: str) -> dict[str, Any]:
"""Resolve an image reference to its OCI config, or a tagged failure.
Return shape: either
{"ok": True, "config": {...}}
or
{"ok": False, "skipped": bool, "network_error": bool, "reason": str}
"""
registry_host, repo, tag = _parse_ref(image_ref)
token, api_base, err = _registry_token(registry_host, repo)
if err is not None:
return err
headers = {"Accept": MANIFEST_ACCEPT}
if token:
headers["Authorization"] = f"Bearer {token}"
manifest_url = f"{api_base}/v2/{repo}/manifests/{tag}"
status, manifest, raw = _curl_json(manifest_url, headers)
if status is None:
return {
"ok": False,
"skipped": False,
"network_error": True,
"reason": f"could not reach {api_base} ({raw[:200]!r})",
}
if status == 404:
return {
"ok": False,
"skipped": False,
"network_error": False,
"reason": f"{image_ref}: HTTP 404 — repository or tag does not exist",
}
if status != 200 or manifest is None:
return {
"ok": False,
"skipped": False,
"network_error": True,
"reason": f"registry returned HTTP {status} resolving the manifest",
}
if "manifests" in manifest: # multi-arch index / manifest list
chosen = None
for m in manifest["manifests"]:
platform = m.get("platform", {})
if platform.get("architecture") == "amd64" and platform.get("os") == "linux":
chosen = m
break
if chosen is None and manifest["manifests"]:
chosen = manifest["manifests"][0]
if chosen is None:
return {
"ok": False,
"skipped": False,
"network_error": False,
"reason": f"{image_ref}: manifest list has no platform entries",
}
digest = chosen["digest"]
status, manifest, raw = _curl_json(
f"{api_base}/v2/{repo}/manifests/{digest}", headers
)
if status is None:
return {
"ok": False,
"skipped": False,
"network_error": True,
"reason": f"could not reach {api_base} resolving platform manifest ({raw[:200]!r})",
}
if status != 200 or manifest is None:
return {
"ok": False,
"skipped": False,
"network_error": True,
"reason": f"registry returned HTTP {status} resolving the platform manifest",
}
config_ref = manifest.get("config", {})
config_digest = config_ref.get("digest")
if not config_digest:
return {
"ok": False,
"skipped": False,
"network_error": False,
"reason": f"{image_ref}: manifest has no config blob reference",
}
blob_headers = {"Accept": "*/*"}
if token:
blob_headers["Authorization"] = f"Bearer {token}"
status, config, raw = _curl_json(
f"{api_base}/v2/{repo}/blobs/{config_digest}", blob_headers
)
if status is None:
return {
"ok": False,
"skipped": False,
"network_error": True,
"reason": f"could not reach {api_base} fetching the config blob ({raw[:200]!r})",
}
if status != 200 or config is None:
return {
"ok": False,
"skipped": False,
"network_error": True,
"reason": f"registry returned HTTP {status} fetching the config blob",
}
return {"ok": True, "config": config.get("config", {}) or {}}
# --------------------------------------------------------------------------
# Small helpers
# --------------------------------------------------------------------------
def get_path(d: dict, dotted: str):
cur: Any = d
for part in dotted.split("."):
if not isinstance(cur, dict) or part not in cur:
return None
cur = cur[part]
return cur
def parse_uid(user_field: str) -> int | None:
if not user_field:
return None
part = user_field.split(":", 1)[0]
try:
return int(part)
except ValueError:
return None
def image_declares_nonroot(user_field: str | None) -> bool:
if not user_field:
return False
part = user_field.split(":", 1)[0]
if part == "root":
return False
uid = parse_uid(user_field)
if uid == 0:
return False
return True
def _dir_prefix_or_equal(path: str, mount: str) -> bool:
"""True if `path` == mount, or `path` is strictly inside the `mount` dir."""
if not path or not mount:
return False
p = path.rstrip("/")
m = mount.rstrip("/")
return p == m or p.startswith(m + "/")
def _steamcmd_shaped(cfg: dict) -> bool:
for e in cfg.get("Env") or []:
key = e.split("=", 1)[0].upper()
if "STEAM" in key:
return True
for field in ("WorkingDir",):
v = (cfg.get(field) or "").lower()
if "steamcmd" in v:
return True
for lst in (cfg.get("Entrypoint") or [], cfg.get("Cmd") or []):
for tok in lst:
if "steamcmd" in (tok or "").lower():
return True
return False
# --------------------------------------------------------------------------
# Rules
# --------------------------------------------------------------------------
def rule_no_entrypoint_shell_cmd(spec: dict, cfg: dict, image_ref: str) -> list[Finding]:
"""Rule 1 (ERROR): no ENTRYPOINT + bare shell CMD needs spec.command.
Historical bug: ARK's image has no ENTRYPOINT and CMD == ["/bin/bash"].
With no TTY, that's a non-interactive bash that reads EOF on stdin and
exits 0 immediately — infinite empty-log restart loop.
Deliberately narrower than "Cmd[0] is a shell binary": `bash script.sh`
(two-element Cmd, e.g. joedwards32/cs2's `["bash", "entry.sh"]`) is a
completely normal, working script invocation, not this bug — only a
*bare* shell with no script argument reproduces the EOF-exit failure,
so this only fires when len(Cmd) == 1.
"""
entrypoint = cfg.get("Entrypoint") or []
cmd = cfg.get("Cmd") or []
if entrypoint or not cmd:
return []
shell_names = {"bash", "sh", "/bin/bash", "/bin/sh"}
base = cmd[0] or ""
basename = base.rsplit("/", 1)[-1]
if len(cmd) == 1 and (base in shell_names or basename in ("bash", "sh")):
if not spec.get("command"):
return [
Finding(
ERROR,
"no-entrypoint-shell-cmd",
f"image {image_ref!r} has no ENTRYPOINT and CMD is bare {cmd!r} — "
"a non-interactive container reads EOF on stdin and exits 0 "
"instantly (restart-loops with an empty log). spec.command "
"MUST be set.",
)
]
return []
def rule_nonroot_requires_runasuser(spec: dict, cfg: dict, image_ref: str) -> list[Finding]:
"""Rule 2 (ERROR): image's non-root User needs a matching runAsUser.
Historical bug: Project Zomboid's image is rootless (uid 10000);
running it as root failed outright.
"""
user = cfg.get("User") or ""
if not image_declares_nonroot(user):
return []
run_as_user = get_path(spec, "security.runAsUser")
if run_as_user is None:
return [
Finding(
ERROR,
"nonroot-user-needs-runasuser",
f"image {image_ref!r} declares non-root User={user!r} but "
"spec.security.runAsUser is unset — the container's data "
"volume needs spec.security.fsGroup too, or the non-root "
"process can't write to a freshly provisioned PVC.",
)
]
img_uid = parse_uid(user)
if img_uid is not None and int(run_as_user) != img_uid:
return [
Finding(
ERROR,
"runasuser-mismatch",
f"image {image_ref!r} User={user!r} resolves to uid {img_uid}, "
f"but spec.security.runAsUser={run_as_user} — these must match "
"numerically (a wrong-but-nonzero uid fails exactly like the "
"Project Zomboid uid-1000-vs-10000 bug).",
)
]
if img_uid is None:
# A bare name (e.g. "steam") — we cannot resolve the real numeric uid
# without reading /etc/passwd out of the image's filesystem layers,
# which this validator deliberately does not do (too expensive for a
# preflight check). Presence of *some* runAsUser is verified above;
# the exact value can't be cross-checked statically here.
pass
return []
def rule_runasuser_requires_home(spec: dict, cfg: dict, image_ref: str) -> list[Finding]:
"""Rule 3: runAsUser without a HOME (image or template) is risky.
Historical bug: Project Zomboid set runAsUser but that silently drops
the $HOME the image's own USER directive would have provided; SteamCMD
resolved "$HOME/Steam" to "//Steam" and died.
Severity is calibrated: ERROR only when the image looks SteamCMD-shaped
(see _steamcmd_shaped and the module docstring for why — ARK sets
runAsUser with no baked/declared HOME and is provably fine, since its
start_server script never reads $HOME).
"""
run_as_user = get_path(spec, "security.runAsUser")
if run_as_user is None:
return []
env_list = cfg.get("Env") or []
if any(e.split("=", 1)[0] == "HOME" for e in env_list):
return []
tmpl_env = spec.get("env") or []
if any(e.get("name") == "HOME" for e in tmpl_env):
return []
steamish = _steamcmd_shaped(cfg)
level = ERROR if steamish else WARN
detail = (
"image looks SteamCMD-shaped (env/paths mention 'steam') — this is "
"the exact Project Zomboid $HOME/Steam pattern"
if steamish
else "no SteamCMD signal detected in this image's static config, so "
"this is advisory rather than a confirmed repro — a static config "
"check can't see whether the entrypoint script even reads $HOME"
)
return [
Finding(
level,
"runasuser-missing-home",
f"image {image_ref!r}: spec.security.runAsUser is set, the image "
f"declares no HOME env, and the template doesn't set one either "
f"({detail}).",
)
]
def rule_mount_shadowing(spec: dict, cfg: dict, image_ref: str) -> list[Finding]:
"""Rule 4: storage.mountPath must not shadow baked-in image content.
Historical bug: Garry's Mod / Project Zomboid / 7 Days to Die all
mounted an empty PVC directly over the directory holding the image's
own entrypoint script (or its whole game install), so the container
couldn't exec at all. Project Zomboid's actual original mountPath was
`/home/steam` — exactly the parent of that image's `entrypoint.sh` —
confirmed in this repo's git history (commit 64fbf82).
ERROR: mountPath equals, or is an ancestor directory of, the resolved
Entrypoint/Cmd executable's absolute path — this is the literal repro
of the historical bug.
WARN only: mountPath equals the image's bare WorkingDir. This is
deliberately not an ERROR — see the module docstring: ARK and
minecraft-java both have mountPath == WorkingDir == a Volume the image
declares for exactly this purpose, and are fine. WorkingDir alone
doesn't tell us whether the image bakes files there; the
Entrypoint/Cmd-prefix check above is the actual strong signal.
WARN also: mountPath is a strict ancestor of a declared image Volume
(not equal to it) — potentially intentional (v-rising covers two
volumes with one parent-mounted PVC, documented in its template), but
worth a human glance in case something else under that root is baked.
"""
mount = get_path(spec, "storage.mountPath")
if not mount:
return []
findings: list[Finding] = []
entrypoint = cfg.get("Entrypoint") or []
cmd = cfg.get("Cmd") or []
exe = entrypoint[0] if entrypoint else (cmd[0] if cmd else None)
if exe and exe.startswith("/") and _dir_prefix_or_equal(exe, mount):
which = "Entrypoint" if entrypoint else "Cmd"
findings.append(
Finding(
ERROR,
"mount-shadows-launcher",
f"image {image_ref!r}: storage.mountPath={mount!r} would shadow "
f"the launcher ({which}[0]={exe!r}) — an empty PVC there means "
"the container can't even exec.",
)
)
return findings # strongest signal found; skip the weaker checks below
working_dir = cfg.get("WorkingDir")
if working_dir and working_dir.rstrip("/") == mount.rstrip("/"):
findings.append(
Finding(
WARN,
"mount-equals-workingdir",
f"image {image_ref!r}: storage.mountPath equals the image's "
f"WorkingDir ({working_dir!r}). Not necessarily a bug (verify "
"case-by-case whether the image bakes files there at build "
"time) — confirm before assuming it's safe.",
)
)
for vol in cfg.get("Volumes") or {}:
if vol.rstrip("/") != mount.rstrip("/") and _dir_prefix_or_equal(vol, mount):
findings.append(
Finding(
WARN,
"mount-is-volume-ancestor",
f"image {image_ref!r}: storage.mountPath={mount!r} is an "
f"ancestor of declared image volume {vol!r}. Fine if "
"covering multiple volumes with one PVC is intentional "
"(document it), but confirm nothing else under that root "
"is baked content.",
)
)
return findings
def rule_image_exists(image_ref: str, result: dict) -> list[Finding]:
"""Rule 5: the declared image must actually resolve on the registry."""
if result.get("ok"):
return []
if result.get("skipped"):
return [Finding(WARN, "image-check-skipped", f"{image_ref!r}: {result['reason']}")]
if result.get("network_error"):
return [
Finding(
WARN,
"image-check-network-error",
f"{image_ref!r}: could not verify the image exists ({result['reason']}) "
"— treated as non-blocking so a registry blip never fails the build.",
)
]
return [Finding(ERROR, "image-not-found", result["reason"])]
def rule_images_pinned(spec: dict) -> list[Finding]:
"""Rule 10: whatever a user gets WITHOUT choosing must be pinned by digest.
A floating tag means a pod restart can swap the game binary under a live
world with no version bump and no changelog. This is not hypothetical:
`passivelemon/terraria-docker:terraria-latest` moved from Terraria 1.4.4.9
to 1.4.5.6, changed the network protocol, and broke the e2e Terraria bot.
The line this rule draws is between defaults and choices:
- ERROR on an unpinned `spec.image`, and on an unpinned version entry
marked `default: true`. Between them these are what a server runs when
the user expressed no preference, so they must be immutable.
- WARN on any other unpinned version entry. A module may legitimately
offer a moving channel (factorio's "Experimental", valheim's "Public
Test") — selecting one is the user's explicit, labelled choice, which
is exactly the escape hatch the roadmap asked for. The WARN keeps them
visible in CI output without blocking them.
This check is pure string inspection with no registry call, so a network
blip can never downgrade it to "couldn't verify" the way the
image-exists rules degrade.
"""
findings: list[Finding] = []
def pinned(ref: str) -> bool:
return "@sha256:" in ref
image = spec.get("image")
if image and not pinned(image):
findings.append(
Finding(
ERROR,
"unpinned-default-image",
f"spec.image={image!r} floats. A server that selects no version runs "
"this image, so an upstream retag silently changes the game binary. "
"Pin it as repo:tag@sha256:… (run `python3 validate.py --pin`); keep a "
"moving tag only in a labelled spec.versions entry.",
)
)
for v in spec.get("versions") or []:
ref = v.get("image")
if not ref or pinned(ref):
continue
vid = v.get("id", "<no id>")
if v.get("default"):
findings.append(
Finding(
ERROR,
"unpinned-default-version",
f"spec.versions[{vid}] is marked default: true but its image "
f"{ref!r} floats — this is what a new server gets without the user "
"choosing. Pin it (`python3 validate.py --pin`).",
)
)
else:
findings.append(
Finding(
WARN,
"unpinned-version-image",
f"spec.versions[{vid}] image {ref!r} floats. That is allowed for a "
"deliberately-moving channel the user picks by name; if this entry "
"is meant to identify a specific build, pin it.",
)
)
return findings
def rule_rcon_protocol(spec: dict) -> list[Finding]:
"""Rule 6: rcon.protocol must be one the agent actually implements."""
rcon = spec.get("rcon")
if not rcon:
return []
proto = rcon.get("protocol")
if proto is None:
# The CRD marks Protocol +kubebuilder:default=source, so an omitted
# key resolves to "source" at apply time — not a missing/invalid value.
return []
if proto not in RCON_PROTOCOLS:
return [
Finding(
ERROR,
"bad-rcon-protocol",
f"rcon.protocol={proto!r} is not one of "
f"{'/'.join(sorted(RCON_PROTOCOLS))} — the agent implements no others.",
)
]
return []
def rule_mods_loaders_requires_versions(spec: dict) -> list[Finding]:
"""Rule 7: capabilities.mods.loaders needs a non-empty spec.versions."""
mods = get_path(spec, "capabilities.mods")
if not mods:
return []
loaders = mods.get("loaders")
if loaders:
versions = spec.get("versions") or []
if not versions:
return [
Finding(
ERROR,
"mods-loaders-without-versions",
"capabilities.mods.loaders is set but spec.versions is "
"empty — no version entry ever selects a loader key, so "
"mods silently disable.",
)
]
return []
def rule_puid_image_needs_fsgroup(spec: dict, cfg: dict, image_ref: str) -> list[Finding]:
"""Rule 8: images with PUID/PGID privilege drop need fsGroup.
Historical bug: thijsvanloef/palworld-server-docker has User: "" (starts as root)
but Env declares PUID=1000/PGID=1000, dropping privileges at runtime. Without
spec.security.fsGroup, the data PVC is root-owned and the dropped-privilege
user can't write — leading to "PalServer.sh is not executable" and restart loops.
ERROR: image declares PUID/PGID in Env but template has no fsGroup.
WARN: template sets runAsUser on a PUID-style image (image needs to start as
root in order to drop privileges).
"""
env_list = cfg.get("Env") or []
has_puid = any(e.split("=", 1)[0] == "PUID" for e in env_list)
has_pgid = any(e.split("=", 1)[0] == "PGID" for e in env_list)
if not (has_puid or has_pgid):
return []
findings: list[Finding] = []
# Check if fsGroup is set
fs_group = get_path(spec, "security.fsGroup")
if fs_group is None:
findings.append(
Finding(
# WARN, not ERROR. A PUID/PGID image is a *risk* signal, not proof of
# breakage: many such images chown the volume themselves while still
# root, before dropping. factorio, rust and terraria all do this and
# run fine with no fsGroup. But palworld does NOT — once dropped it
# could not chmod +x the files SteamCMD had just installed
# ("./PalServer.sh is not executable."), exited 0, and restart-looped
# 92 times. So: flag it for a human, don't fail the build.
WARN,
"puid-image-needs-fsgroup",
f"image {image_ref!r} declares PUID/PGID in Env (its entrypoint drops "
"privileges at runtime) but spec.security.fsGroup is unset. If the image "
"does not chown the data volume before dropping, the dropped user cannot "
"write its own install — palworld hit exactly this ('./PalServer.sh is not "
"executable.', exit 0, 92 restarts). Verify, and set spec.security.fsGroup "
"to the PUID if needed. Do NOT set runAsUser: the image must start as root "
"in order to drop.",
)
)
# Warn if template incorrectly sets runAsUser on a PUID image
run_as_user = get_path(spec, "security.runAsUser")
if run_as_user is not None:
findings.append(
Finding(
WARN,
"puid-image-with-runasuser",
f"image {image_ref!r} declares PUID/PGID (needs to start as root "
"to drop privileges), but spec.security.runAsUser={run_as_user} — "
"this forces the container to start as non-root, preventing the "
"privilege-drop mechanism from working. Remove spec.security.runAsUser "
"and rely on fsGroup for permissions.",
)
)
return findings
def rule_credential_fields_must_be_password(spec: dict) -> list[Finding]:
"""Rule 9: credential-shaped config fields must have type: password.
Config fields whose names look like credentials (PASSWORD, TOKEN, SECRET,
etc.) are stored in plaintext in the GameServer CR and etcd if type is not
'password'. Only 'type: password' stores values in a per-GameServer Secret
and injects them via SecretKeyRef — the value never lands inline in the CR,
pod spec, or etcd.
Historical bug (caught in cs2): SRCDS_TOKEN (a Steam Game Server Login Token)
was declared as 'type: string', so it would have been stored in plaintext in
the CR and visible to anyone with kubectl read access.
ERROR: name looks credential-shaped but type is not 'password'.
Skip: type is 'bool' or 'int' (a boolean flag named 'PASSWORD_ENABLED' is not
a credential even if the name matches).
"""
config_schema = spec.get("configSchema") or []
findings: list[Finding] = []
# Credential name patterns to match (case-insensitive)
credential_substrings = {
"PASSWORD",
"PASSWD",
"PASS",
"TOKEN",
"SECRET",
"APIKEY",
"API_KEY",
"AUTHKEY",
"GSLT",
"CREDENTIAL",
}
for entry in config_schema:
if not isinstance(entry, dict):