Coverage for dockerfiles / build_scratch_rootfs.py: 100.00%

229 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1"""Assemble the distroless runtime rootfs the service images copy onto scratch. 

2 

3Every service Dockerfile in this directory is a two-stage build: a 

4``python:X.Y.Z-slim`` builder installs the locked dependency set, applies the 

5APT security patches, and precompiles the app tree; then this script stages a 

6minimal root filesystem under ``/rootfs`` which the final ``FROM scratch`` 

7stage copies wholesale. The deployed image therefore contains no shell, no 

8package manager, no coreutils — only the CPython runtime, the service's 

9site-packages, the application tree, and the exact shared libraries those 

10binaries link against. 

11 

12Why hand-assembled scratch instead of gcr.io/distroless: 

13- The platform pins an exact CPython 3.14 patch release (the builder's 

14 ``python:X.Y.Z-slim`` tag); no distroless base ships it (Google's 

15 ``distroless/python3`` tracks Debian's interpreter, currently 3.13). 

16- Copying the ELF closure from the *patched* builder keeps the existing 

17 APT_SECURITY_EPOCH workflow meaningful: ``apt-get upgrade`` in the builder 

18 is what patches the glibc/OpenSSL bits that actually ship. 

19- No second upstream registry: ``python:<pin>-slim`` remains the only base 

20 image dependency, watched by the existing Dependabot docker config. 

21 

22Scanner visibility is preserved deliberately: for every Debian package that 

23owns a copied file, its dpkg status paragraph is written to 

24``/var/lib/dpkg/status.d/<package>`` (the distroless convention Trivy reads) 

25and ``/etc/os-release`` is carried over, so ``security:trivy:container-scan`` 

26keeps flagging CVEs in the shipped libraries instead of going blind. Each 

27package's ``/usr/share/doc/<pkg>/copyright`` ships too (license compliance 

28for the redistributed Debian bits). 

29 

30The script is stdlib-only, runs as root inside the builder stage, and fails 

31loudly: an unresolvable ``ldd`` entry, a library with no owning package, or a 

32missing trust anchor each abort the image build rather than surfacing as a 

33crash-looping pod. 

34 

35The verification contract is derived, not maintained: this script probes 

36which stdlib C extensions are actually importable in the builder (everything 

37under ``lib-dynload``, each imported in an isolated subprocess) and writes 

38the result as ``runtime_smoke_manifest.json`` next to ``runtime_smoke.py`` 

39in ``/opt/build``. The final stage reaches both through a BuildKit bind 

40mount that exists only for its smoke RUN — the deployed image ships no build 

41tooling — and requires every builder-importable extension to import on 

42scratch as the runtime user. That builder-to-scratch parity also catches 

43``dlopen``'d libraries ldd cannot see, so a closure gap in a *new* 

44dependency breaks the build, not the deployment, without anyone curating a 

45module list by hand. 

46""" 

47 

48from __future__ import annotations 

49 

50import json 

51import os 

52import re 

53import shutil 

54import subprocess 

55import sys 

56import sysconfig 

57from pathlib import Path 

58 

59ROOTFS = Path("/rootfs") 

60USR_LOCAL = Path("/usr/local") 

61APP_TREE = Path("/app/gco") 

62DPKG_STATUS = Path("/var/lib/dpkg/status") 

63 

64# The builder filesystem everything is read from. Every path in this module is 

65# written as the absolute builder path (``/etc/ssl/certs``, ``/usr/local``, 

66# ...) and staged under ROOTFS at that same path; reads go through ``host()`` 

67# so the test-suite can point SYSROOT at a synthetic builder tree and run the 

68# whole assembly hermetically. In the image build this is ``/`` and ``host()`` 

69# is the identity. 

70SYSROOT = Path("/") 

71 

72 

73def host(path: Path) -> Path: 

74 """Where the builder file at absolute ``path`` is read from.""" 

75 return SYSROOT / path.relative_to("/") 

76 

77 

78def host_glob(directory: Path, pattern: str) -> list[Path]: 

79 """Sorted absolute builder paths under ``directory`` matching ``pattern``.""" 

80 return sorted(Path("/") / match.relative_to(SYSROOT) for match in host(directory).glob(pattern)) 

81 

82 

83# Runtime identity baked into the synthesized /etc/passwd. Matches the 

84# runAsUser/runAsGroup 1000 enforced by every pod securityContext. 

85RUNTIME_USER = "gco" 

86RUNTIME_UID = 1000 

87RUNTIME_HOME = "/home/gco" 

88 

89# Enumeration/probe sanity floor: these stdlib extensions must exist and be 

90# importable in every supported builder image. This is not a maintained 

91# feature list — the manifest itself is fully derived — it is a tripwire so 

92# a silently broken glob or probe (yielding an empty or gutted manifest, and 

93# with it a vacuously passing smoke) fails the build instead. 

94CRITICAL_STDLIB_EXTENSIONS = frozenset( 

95 {"_bz2", "_ctypes", "_hashlib", "_lzma", "_socket", "_sqlite3", "_ssl", "_zoneinfo", "zlib"} 

96) 

97 

98# dlopen'd by glibc for thread-cancellation unwinding; never appears as a 

99# DT_NEEDED of CPython, so ldd cannot discover it. Seeded explicitly. 

100FORCED_LIBS = ("libgcc_s.so.1",) 

101# Legacy NSS plugins. glibc 2.41 (trixie) has files/dns builtin, but ship 

102# them when present so name resolution keeps working even if the base image 

103# ever reverts to plugin-based lookup. 

104OPTIONAL_NSS_LIBS = ("libnss_files.so.2", "libnss_dns.so.2") 

105 

106_LDD_RESOLVED = re.compile(r"^\s*\S+\s+=>\s+(/\S+)\s+\(0x[0-9a-f]+\)\s*$") 

107_LDD_DIRECT = re.compile(r"^\s*(/\S+)\s+\(0x[0-9a-f]+\)\s*$") 

108 

109 

110def fail(message: str) -> None: 

111 print(f"build_scratch_rootfs: ERROR: {message}", file=sys.stderr) 

112 sys.exit(1) 

113 

114 

115def multiarch_dir() -> Path: 

116 triplet = sysconfig.get_config_var("MULTIARCH") 

117 if not triplet: 

118 fail("sysconfig reports no MULTIARCH triplet") 

119 return Path("/usr/lib") / str(triplet) 

120 

121 

122# Debian is merged-/usr: /lib, /lib64, /bin, /sbin are symlinks into /usr and 

123# every file physically lives there. ldd reports the alias paths (the kernel's 

124# PT_INTERP is /lib64/ld-linux-*.so.*), so staged paths are normalized into 

125# /usr and the aliases are recreated as symlinks, mirroring the builder. 

126_MERGED_USR_ALIASES = ("lib", "lib64", "bin", "sbin") 

127 

128 

129def canonical_usr_path(source: Path) -> Path: 

130 """Rewrite a merged-/usr alias path (/lib/..., /bin/...) to its /usr form.""" 

131 parts = source.relative_to("/").parts 

132 if parts and parts[0] in _MERGED_USR_ALIASES: 

133 return Path("/usr").joinpath(*parts) 

134 return source 

135 

136 

137def stage_path(source: Path) -> Path: 

138 return ROOTFS / canonical_usr_path(source).relative_to("/") 

139 

140 

141def copy_file(source: Path) -> None: 

142 """Copy one regular file into the rootfs, preserving mode.""" 

143 destination = stage_path(source) 

144 destination.parent.mkdir(parents=True, exist_ok=True) 

145 if not destination.exists(): 

146 shutil.copy2(host(source), destination, follow_symlinks=False) 

147 

148 

149def replicate_symlink_chain(path: Path) -> Path: 

150 """Recreate ``path`` in the rootfs, link by link, returning the real file. 

151 

152 Debian SONAME paths are symlink chains (e.g. ``libz.so.1`` -> 

153 ``libz.so.1.3.1``); the dynamic linker resolves the chain at runtime, so 

154 every hop must exist in the final image exactly as it does in the builder. 

155 """ 

156 current = path 

157 for _ in range(16): 

158 destination = stage_path(current) 

159 destination.parent.mkdir(parents=True, exist_ok=True) 

160 if host(current).is_symlink(): 

161 target = os.readlink(host(current)) 

162 if not destination.is_symlink(): 

163 destination.symlink_to(target) 

164 current = Path(os.path.normpath(current.parent / target)) 

165 continue 

166 copy_file(current) 

167 return current 

168 fail(f"symlink chain too deep at {path}") 

169 raise AssertionError # unreachable 

170 

171 

172def seed_binaries() -> list[Path]: 

173 """Every ELF object whose dependency closure must ship.""" 

174 seeds = [USR_LOCAL / "bin" / f"python{sys.version_info.major}.{sys.version_info.minor}"] 

175 # Stdlib C extensions (drives libsqlite3, liblzma, libffi, libssl, ...). 

176 seeds += host_glob(USR_LOCAL / "lib", "python*/lib-dynload/*.so") 

177 # libpython itself plus every compiled site-packages extension. manylinux 

178 # policy caps their externals at glibc/libgcc/libstdc++, but the closure 

179 # is computed from the actual binaries rather than trusting the policy. 

180 seeds += host_glob(USR_LOCAL / "lib", "libpython*.so*") 

181 seeds += host_glob(USR_LOCAL / "lib", "python*/site-packages/**/*.so*") 

182 lib_dir = multiarch_dir() 

183 for name in FORCED_LIBS: 

184 forced = lib_dir / name 

185 if not host(forced).exists(): 

186 fail(f"forced library missing from builder: {forced}") 

187 seeds.append(forced) 

188 for name in OPTIONAL_NSS_LIBS: 

189 optional = lib_dir / name 

190 if host(optional).exists(): 

191 seeds.append(optional) 

192 return [seed for seed in seeds if not host(seed).is_dir()] 

193 

194 

195def resolve_closure(seeds: list[Path]) -> set[Path]: 

196 """Union of ldd-resolved shared-object paths across all seeds. 

197 

198 Seeds are ldd'd in chunks with per-file attribution. A stdlib 

199 ``lib-dynload`` extension with unresolvable dependencies is skipped with a 

200 notice instead of failing: ``python:*-slim`` itself ships such modules 

201 (``_tkinter`` links libtk/libX11, which slim never installs), so they are 

202 equally unimportable in today's images — excluding them preserves parity. 

203 Unresolved dependencies anywhere else (interpreter, libpython, 

204 site-packages, forced seeds) abort the build: those are objects the 

205 services can actually reach at runtime. 

206 """ 

207 per_seed_libs: dict[str, set[Path]] = {} 

208 per_seed_missing: dict[str, list[str]] = {} 

209 for start in range(0, len(seeds), 64): 

210 chunk = [str(path) for path in seeds[start : start + 64]] 

211 result = subprocess.run(["ldd", *chunk], capture_output=True, text=True, check=False) 

212 # With multiple arguments ldd prefixes each file's section with a 

213 # "<path>:" header; with a single argument it prints none. 

214 current = chunk[0] 

215 for line in result.stdout.splitlines(): 

216 if line.startswith("/") and line.rstrip().endswith(":"): 

217 current = line.rstrip().rstrip(":") 

218 continue 

219 if "not a dynamic executable" in line or "statically linked" in line: 

220 continue 

221 if "not found" in line: 

222 per_seed_missing.setdefault(current, []).append(line.strip()) 

223 continue 

224 match = _LDD_RESOLVED.match(line) or _LDD_DIRECT.match(line) 

225 if match: 

226 per_seed_libs.setdefault(current, set()).add(Path(match.group(1))) 

227 

228 resolved: set[Path] = set() 

229 for seed, libraries in per_seed_libs.items(): 

230 if seed not in per_seed_missing: 

231 resolved.update(libraries) 

232 for seed, missing in sorted(per_seed_missing.items()): 

233 if "/lib-dynload/" in seed: 

234 print( 

235 f"build_scratch_rootfs: skipping stdlib extension already " 

236 f"broken in the builder image: {Path(seed).name} " 

237 f"(missing: {', '.join(missing)})" 

238 ) 

239 else: 

240 fail(f"unresolved shared library dependency in {seed}: {missing}") 

241 if not resolved: 

242 fail("ldd resolved no shared libraries; closure computation broke") 

243 return resolved 

244 

245 

246def owning_packages(real_files: set[Path]) -> set[str]: 

247 """Map copied real files to the Debian packages that own them.""" 

248 packages: set[str] = set() 

249 unmatched: set[Path] = set() 

250 # dpkg's database records merged-/usr files under /usr; query that form. 

251 paths = sorted({str(canonical_usr_path(path)) for path in real_files}) 

252 for start in range(0, len(paths), 64): 

253 chunk = paths[start : start + 64] 

254 result = subprocess.run(["dpkg", "-S", *chunk], capture_output=True, text=True, check=False) 

255 matched_in_chunk: set[str] = set() 

256 for line in result.stdout.splitlines(): 

257 head, separator, path = line.partition(": ") 

258 if not separator or "diversion" in head: 

259 continue 

260 matched_in_chunk.add(path.strip()) 

261 for name in head.split(","): 

262 packages.add(name.strip().split(":")[0]) 

263 unmatched.update(Path(path) for path in chunk if path not in matched_in_chunk) 

264 orphans = {path for path in unmatched if not str(path).startswith("/usr/local/")} 

265 if orphans: 

266 # /usr/local is CPython + wheels (not dpkg-owned, same as today's 

267 # images); anything else without provenance is a hard error. 

268 fail(f"copied libraries with no owning Debian package: {sorted(orphans)}") 

269 return packages 

270 

271 

272def write_dpkg_metadata(packages: set[str]) -> None: 

273 """Emit distroless-style /var/lib/dpkg/status.d entries + copyright files. 

274 

275 Trivy identifies Debian packages in shell-less images from status.d; 

276 omitting this would silently exempt the shipped glibc/OpenSSL from the CI 

277 container scan, which is the opposite of the point. 

278 """ 

279 paragraphs: dict[str, str] = {} 

280 for paragraph in host(DPKG_STATUS).read_text(encoding="utf-8").split("\n\n"): 

281 match = re.search(r"^Package:\s*(\S+)", paragraph, re.MULTILINE) 

282 if match: 

283 paragraphs[match.group(1)] = paragraph.strip() + "\n" 

284 status_dir = stage_path(Path("/var/lib/dpkg/status.d")) 

285 status_dir.mkdir(parents=True, exist_ok=True) 

286 for package in sorted(packages): 

287 if package not in paragraphs: 

288 fail(f"package {package} owns shipped files but has no status paragraph") 

289 (status_dir / package).write_text(paragraphs[package], encoding="utf-8") 

290 copyright_file = Path("/usr/share/doc") / package / "copyright" 

291 if host(copyright_file).exists(): 

292 destination = stage_path(copyright_file) 

293 destination.parent.mkdir(parents=True, exist_ok=True) 

294 shutil.copy2(host(copyright_file), destination) 

295 else: 

296 fail(f"missing license text for redistributed package: {copyright_file}") 

297 

298 

299def copy_trust_and_time() -> None: 

300 """CA trust anchors (TLS to AWS APIs) and zoneinfo.""" 

301 # Dereference the hashed-symlink farm into real files so nothing dangles 

302 # (the links point into /usr/share/ca-certificates, which does not ship). 

303 certs = Path("/etc/ssl/certs") 

304 shutil.copytree(host(certs), stage_path(certs), symlinks=False) 

305 for config in (Path("/etc/ssl/openssl.cnf"),): 

306 if host(config).exists(): 

307 copy_file(config) 

308 # OpenSSL's compiled-in OPENSSLDIR: replicate its symlinks verbatim; their 

309 # /etc/ssl targets were materialized above. 

310 ssl_dir = Path("/usr/lib/ssl") 

311 for child in host(ssl_dir).iterdir(): 

312 entry = ssl_dir / child.name 

313 destination = stage_path(entry) 

314 destination.parent.mkdir(parents=True, exist_ok=True) 

315 if child.is_symlink(): 

316 destination.symlink_to(os.readlink(child)) 

317 elif child.is_file(): 

318 copy_file(entry) 

319 stage_path(Path("/etc/ssl/private")).mkdir(mode=0o700, parents=True, exist_ok=True) 

320 bundle = stage_path(Path("/etc/ssl/certs/ca-certificates.crt")) 

321 if not bundle.exists() or bundle.stat().st_size == 0: 

322 fail("CA bundle missing or empty after staging") 

323 

324 zoneinfo = Path("/usr/share/zoneinfo") 

325 shutil.copytree(host(zoneinfo), stage_path(zoneinfo), symlinks=True) 

326 stage_path(Path("/etc/localtime")).symlink_to("/usr/share/zoneinfo/Etc/UTC") 

327 stage_path(Path("/etc/timezone")).write_text("Etc/UTC\n", encoding="utf-8") 

328 

329 

330_PROBE_CODE = """ 

331import importlib 

332import json 

333import sys 

334 

335results = {} 

336for name in json.load(sys.stdin): 

337 try: 

338 importlib.import_module(name) 

339 results[name] = None 

340 except BaseException as exc: # noqa: BLE001 — record every failure mode 

341 results[name] = f"{type(exc).__name__}: {exc}" 

342print(json.dumps(results)) 

343""" 

344 

345 

346def probe_stdlib_extensions() -> tuple[list[str], dict[str, str]]: 

347 """Import every ``lib-dynload`` extension in the builder; split the result. 

348 

349 Returns ``(importable, broken)``. The probe runs in one isolated 

350 subprocess (``-I``: no env, no cwd on ``sys.path``) so the assembly 

351 process stays untouched and the result reflects a clean interpreter. 

352 ``broken`` covers extensions the *builder itself* cannot import — 

353 ``python:*-slim`` ships ``_tkinter`` without libtk, for example — which 

354 are exactly the ones the runtime smoke must not demand on scratch. 

355 """ 

356 modules = sorted( 

357 { 

358 path.name.split(".")[0] 

359 for path in host_glob(USR_LOCAL / "lib", "python*/lib-dynload/*.so") 

360 } 

361 ) 

362 if not modules: 

363 fail("no lib-dynload extensions found; stdlib enumeration broke") 

364 result = subprocess.run( 

365 [sys.executable, "-I", "-c", _PROBE_CODE], 

366 input=json.dumps(modules), 

367 capture_output=True, 

368 text=True, 

369 check=False, 

370 ) 

371 if result.returncode != 0: 

372 fail(f"stdlib import probe crashed: {result.stderr.strip()}") 

373 outcomes: dict[str, str | None] = json.loads(result.stdout) 

374 importable = sorted(name for name, error in outcomes.items() if error is None) 

375 broken = {name: error for name, error in sorted(outcomes.items()) if error is not None} 

376 missing_critical = CRITICAL_STDLIB_EXTENSIONS - set(importable) 

377 if missing_critical: 

378 fail( 

379 "stdlib probe sanity floor violated — critical extensions not importable " 

380 f"in the builder: {sorted(missing_critical)}" 

381 ) 

382 return importable, broken 

383 

384 

385def write_runtime_smoke_manifest(importable: list[str], broken: dict[str, str]) -> None: 

386 """Write the derived parity manifest next to this script (in the builder). 

387 

388 The manifest is the builder-to-scratch parity contract: every extension 

389 listed must import in the final image. It deliberately lands in 

390 ``/opt/build`` — NOT in the staged rootfs — because the final stage 

391 reaches it through a BuildKit bind mount that exists only for the smoke 

392 RUN, so no build tooling ships in the deployed image. ``expected_broken`` 

393 is an audit trail of what the builder itself could not import (and why), 

394 so a reader of the build log can distinguish "excluded by parity" from 

395 "forgotten". 

396 """ 

397 smoke_source = Path(__file__).with_name("runtime_smoke.py") 

398 if not smoke_source.is_file(): 

399 fail(f"runtime_smoke.py not found next to this script: {smoke_source}") 

400 manifest = { 

401 "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", 

402 "runtime_user": RUNTIME_USER, 

403 "stdlib_extensions": importable, 

404 "expected_broken": broken, 

405 } 

406 Path(__file__).with_name("runtime_smoke_manifest.json").write_text( 

407 json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" 

408 ) 

409 print( 

410 f"build_scratch_rootfs: runtime smoke manifest lists {len(importable)} " 

411 f"builder-importable stdlib extensions; expected-broken: " 

412 f"{', '.join(broken) or 'none'}" 

413 ) 

414 

415 

416def write_identity_and_os_metadata() -> None: 

417 """Minimal NSS database, os-release, and top-level filesystem shape.""" 

418 etc = stage_path(Path("/etc")) 

419 etc.mkdir(parents=True, exist_ok=True) 

420 (etc / "passwd").write_text( 

421 "root:x:0:0:root:/root:/usr/sbin/nologin\n" 

422 f"{RUNTIME_USER}:x:{RUNTIME_UID}:{RUNTIME_UID}:{RUNTIME_USER}:" 

423 f"{RUNTIME_HOME}:/usr/sbin/nologin\n", 

424 encoding="utf-8", 

425 ) 

426 (etc / "group").write_text(f"root:x:0:\n{RUNTIME_USER}:x:{RUNTIME_UID}:\n", encoding="utf-8") 

427 (etc / "nsswitch.conf").write_text( 

428 "passwd: files\ngroup: files\nhosts: files dns\n", encoding="utf-8" 

429 ) 

430 # OS identification for scanners and humans. Trivy's Debian detection 

431 # keys on /etc/debian_version (verified empirically — os-release alone 

432 # yields family "none" and silently disables the dpkg CVE mapping); 

433 # os-release ships too, as a regular file at both canonical paths 

434 # (Debian's /etc symlink form is invisible to scanners that read tar 

435 # layers without symlink resolution). 

436 copy_file(Path("/etc/debian_version")) 

437 copy_file(Path("/usr/lib/os-release")) 

438 shutil.copy2(host(Path("/usr/lib/os-release")), etc / "os-release", follow_symlinks=True) 

439 

440 # Merged-/usr symlinks. The kernel resolves PT_INTERP 

441 # (/lib64/ld-linux-*.so.*) through these; without them nothing executes. 

442 for alias in _MERGED_USR_ALIASES: 

443 if host(Path("/", alias)).is_symlink() and (ROOTFS / f"usr/{alias}").exists(): 

444 (ROOTFS / alias).symlink_to(f"usr/{alias}") 

445 

446 home = stage_path(Path(RUNTIME_HOME)) 

447 home.mkdir(parents=True, exist_ok=True) 

448 for scratch_dir in ("tmp", "var/tmp"): 

449 path = ROOTFS / scratch_dir 

450 path.mkdir(parents=True, exist_ok=True) 

451 path.chmod(0o1777) 

452 

453 

454def main() -> None: 

455 if ROOTFS.exists(): 

456 fail("/rootfs already exists; refusing to assemble over prior state") 

457 

458 # The interpreter, stdlib, and site-packages, minus ensurepip: pip itself 

459 # is uninstalled by the Dockerfile, and dropping ensurepip's bundled pip 

460 # wheel keeps "reinstall the installer" out of reach at runtime too. 

461 shutil.copytree( 

462 host(USR_LOCAL), 

463 stage_path(USR_LOCAL), 

464 symlinks=True, 

465 ignore=shutil.ignore_patterns("ensurepip"), 

466 ) 

467 # The precompiled application tree (sole content of /app besides cwd). 

468 shutil.copytree(host(APP_TREE), stage_path(APP_TREE), symlinks=True) 

469 

470 closure = resolve_closure(seed_binaries()) 

471 real_files: set[Path] = set() 

472 for library in sorted(closure): 

473 real_files.add(replicate_symlink_chain(library)) 

474 

475 write_identity_and_os_metadata() 

476 copy_trust_and_time() 

477 

478 importable, broken = probe_stdlib_extensions() 

479 write_runtime_smoke_manifest(importable, broken) 

480 

481 packages = owning_packages(real_files) 

482 # Always attribute the non-library payloads staged above. 

483 packages.update({"ca-certificates", "tzdata", "base-files"}) 

484 write_dpkg_metadata(packages) 

485 

486 # Pre-warm the dynamic linker cache for the staged tree (ld.so falls back 

487 # to default path search without it, but the cache is free to generate). 

488 subprocess.run(["ldconfig", "-r", str(ROOTFS)], check=True) 

489 

490 interpreter = [path for path in closure if "ld-linux" in path.name] 

491 if not interpreter: 

492 fail("dynamic linker never entered the closure") 

493 print( 

494 f"build_scratch_rootfs: staged {len(closure)} shared objects from " 

495 f"{len(packages)} Debian packages: {' '.join(sorted(packages))}" 

496 ) 

497 

498 

499if __name__ == "__main__": 

500 main()