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
« 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.
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.
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.
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).
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.
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"""
47from __future__ import annotations
49import json
50import os
51import re
52import shutil
53import subprocess
54import sys
55import sysconfig
56from pathlib import Path
58ROOTFS = Path("/rootfs")
59USR_LOCAL = Path("/usr/local")
60APP_TREE = Path("/app/gco")
61DPKG_STATUS = Path("/var/lib/dpkg/status")
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("/")
72def host(path: Path) -> Path:
73 """Where the builder file at absolute ``path`` is read from."""
74 return SYSROOT / path.relative_to("/")
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))
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"
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)
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")
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*$")
109def fail(message: str) -> None:
110 print(f"build_scratch_rootfs: ERROR: {message}", file=sys.stderr)
111 sys.exit(1)
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)
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")
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
136def stage_path(source: Path) -> Path:
137 return ROOTFS / canonical_usr_path(source).relative_to("/")
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)
148def replicate_symlink_chain(path: Path) -> Path:
149 """Recreate ``path`` in the rootfs, link by link, returning the real file.
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
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()]
194def resolve_closure(seeds: list[Path]) -> set[Path]:
195 """Union of ldd-resolved shared-object paths across all seeds.
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)))
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
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
271def write_dpkg_metadata(packages: set[str]) -> None:
272 """Emit distroless-style /var/lib/dpkg/status.d entries + copyright files.
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}")
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")
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")
329_PROBE_CODE = """
330import importlib
331import json
332import sys
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"""
345def probe_stdlib_extensions() -> tuple[list[str], dict[str, str]]:
346 """Import every ``lib-dynload`` extension in the builder; split the result.
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
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).
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 )
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)
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}")
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)
453def main() -> None:
454 if ROOTFS.exists():
455 fail("/rootfs already exists; refusing to assemble over prior state")
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)
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))
474 write_identity_and_os_metadata()
475 copy_trust_and_time()
477 importable, broken = probe_stdlib_extensions()
478 write_runtime_smoke_manifest(importable, broken)
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)
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)
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 )
498if __name__ == "__main__":
499 main()