Coverage for dockerfiles / build_scratch_rootfs.py: 100.00%

229 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +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 CPython 3.14.6; no distroless base ships it (Google's 

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

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

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

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

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

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

20 

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

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

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

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

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

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

27for the redistributed Debian bits). 

28 

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

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

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

32crash-looping pod. 

33 

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

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

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

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

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

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

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

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

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

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

44module list by hand. 

45""" 

46 

47from __future__ import annotations 

48 

49import json 

50import os 

51import re 

52import shutil 

53import subprocess 

54import sys 

55import sysconfig 

56from pathlib import Path 

57 

58ROOTFS = Path("/rootfs") 

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

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

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

62 

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

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

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

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

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

68# is the identity. 

69SYSROOT = Path("/") 

70 

71 

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

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

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

75 

76 

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

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

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

80 

81 

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

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

84RUNTIME_USER = "gco" 

85RUNTIME_UID = 1000 

86RUNTIME_HOME = "/home/gco" 

87 

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

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

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

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

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

93CRITICAL_STDLIB_EXTENSIONS = frozenset( 

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

95) 

96 

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

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

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

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

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

102# ever reverts to plugin-based lookup. 

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

104 

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

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

107 

108 

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

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

111 sys.exit(1) 

112 

113 

114def multiarch_dir() -> Path: 

115 triplet = sysconfig.get_config_var("MULTIARCH") 

116 if not triplet: 

117 fail("sysconfig reports no MULTIARCH triplet") 

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

119 

120 

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

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

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

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

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

126 

127 

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

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

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

131 if parts and parts[0] in _MERGED_USR_ALIASES: 

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

133 return source 

134 

135 

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

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

138 

139 

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

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

142 destination = stage_path(source) 

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

144 if not destination.exists(): 

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

146 

147 

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

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

150 

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

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

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

154 """ 

155 current = path 

156 for _ in range(16): 

157 destination = stage_path(current) 

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

159 if host(current).is_symlink(): 

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

161 if not destination.is_symlink(): 

162 destination.symlink_to(target) 

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

164 continue 

165 copy_file(current) 

166 return current 

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

168 raise AssertionError # unreachable 

169 

170 

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

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

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

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

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

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

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

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

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

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

181 lib_dir = multiarch_dir() 

182 for name in FORCED_LIBS: 

183 forced = lib_dir / name 

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

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

186 seeds.append(forced) 

187 for name in OPTIONAL_NSS_LIBS: 

188 optional = lib_dir / name 

189 if host(optional).exists(): 

190 seeds.append(optional) 

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

192 

193 

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

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

196 

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

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

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

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

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

202 Unresolved dependencies anywhere else (interpreter, libpython, 

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

204 services can actually reach at runtime. 

205 """ 

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

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

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

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

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

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

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

213 current = chunk[0] 

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

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

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

217 continue 

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

219 continue 

220 if "not found" in line: 

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

222 continue 

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

224 if match: 

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

226 

227 resolved: set[Path] = set() 

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

229 if seed not in per_seed_missing: 

230 resolved.update(libraries) 

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

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

233 print( 

234 f"build_scratch_rootfs: skipping stdlib extension already " 

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

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

237 ) 

238 else: 

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

240 if not resolved: 

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

242 return resolved 

243 

244 

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

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

247 packages: set[str] = set() 

248 unmatched: set[Path] = set() 

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

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

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

252 chunk = paths[start : start + 64] 

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

254 matched_in_chunk: set[str] = set() 

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

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

257 if not separator or "diversion" in head: 

258 continue 

259 matched_in_chunk.add(path.strip()) 

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

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

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

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

264 if orphans: 

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

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

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

268 return packages 

269 

270 

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

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

273 

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

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

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

277 """ 

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

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

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

281 if match: 

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

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

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

285 for package in sorted(packages): 

286 if package not in paragraphs: 

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

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

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

290 if host(copyright_file).exists(): 

291 destination = stage_path(copyright_file) 

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

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

294 else: 

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

296 

297 

298def copy_trust_and_time() -> None: 

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

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

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

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

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

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

305 if host(config).exists(): 

306 copy_file(config) 

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

308 # /etc/ssl targets were materialized above. 

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

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

311 entry = ssl_dir / child.name 

312 destination = stage_path(entry) 

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

314 if child.is_symlink(): 

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

316 elif child.is_file(): 

317 copy_file(entry) 

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

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

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

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

322 

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

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

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

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

327 

328 

329_PROBE_CODE = """ 

330import importlib 

331import json 

332import sys 

333 

334results = {} 

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

336 try: 

337 importlib.import_module(name) 

338 results[name] = None 

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

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

341print(json.dumps(results)) 

342""" 

343 

344 

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

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

347 

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

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

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

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

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

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

354 """ 

355 modules = sorted( 

356 { 

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

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

359 } 

360 ) 

361 if not modules: 

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

363 result = subprocess.run( 

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

365 input=json.dumps(modules), 

366 capture_output=True, 

367 text=True, 

368 check=False, 

369 ) 

370 if result.returncode != 0: 

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

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

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

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

375 missing_critical = CRITICAL_STDLIB_EXTENSIONS - set(importable) 

376 if missing_critical: 

377 fail( 

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

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

380 ) 

381 return importable, broken 

382 

383 

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

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

386 

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

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

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

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

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

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

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

394 "forgotten". 

395 """ 

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

397 if not smoke_source.is_file(): 

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

399 manifest = { 

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

401 "runtime_user": RUNTIME_USER, 

402 "stdlib_extensions": importable, 

403 "expected_broken": broken, 

404 } 

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

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

407 ) 

408 print( 

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

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

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

412 ) 

413 

414 

415def write_identity_and_os_metadata() -> None: 

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

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

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

419 (etc / "passwd").write_text( 

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

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

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

423 encoding="utf-8", 

424 ) 

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

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

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

428 ) 

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

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

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

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

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

434 # layers without symlink resolution). 

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

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

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

438 

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

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

441 for alias in _MERGED_USR_ALIASES: 

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

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

444 

445 home = stage_path(Path(RUNTIME_HOME)) 

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

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

448 path = ROOTFS / scratch_dir 

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

450 path.chmod(0o1777) 

451 

452 

453def main() -> None: 

454 if ROOTFS.exists(): 

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

456 

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

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

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

460 shutil.copytree( 

461 host(USR_LOCAL), 

462 stage_path(USR_LOCAL), 

463 symlinks=True, 

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

465 ) 

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

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

468 

469 closure = resolve_closure(seed_binaries()) 

470 real_files: set[Path] = set() 

471 for library in sorted(closure): 

472 real_files.add(replicate_symlink_chain(library)) 

473 

474 write_identity_and_os_metadata() 

475 copy_trust_and_time() 

476 

477 importable, broken = probe_stdlib_extensions() 

478 write_runtime_smoke_manifest(importable, broken) 

479 

480 packages = owning_packages(real_files) 

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

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

483 write_dpkg_metadata(packages) 

484 

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

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

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

488 

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

490 if not interpreter: 

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

492 print( 

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

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

495 ) 

496 

497 

498if __name__ == "__main__": 

499 main()