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