Coverage for gco / service_images.py: 100.00%
51 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"""Discovery of the container images this repository builds.
3There is deliberately no hand-kept list of service images anywhere in the
4tree. Every in-cluster platform service is built from
5``dockerfiles/Dockerfile.<service>`` and that filename *is* the catalog: the
6regional stack (image assets and asset-hash excludes), the CLI's maintained
7image inventory, the CI build/scan matrix and the inventory guard test all
8read this module, so adding a service is a matter of adding its Dockerfile.
10Three image families are discovered, each from the tree rather than listed:
12* **platform services** — ``dockerfiles/Dockerfile.<service>``, built with
13 the repository root as context (they ``COPY`` shared ``gco/`` code);
14* **container Lambdas** — ``lambda/<name>/Dockerfile``, built with the
15 Lambda's own directory as context (``*-build`` staging dirs are skipped);
16* **root images** — ``Dockerfile.<name>`` at the repository root (today the
17 contributor dev container), built from the root.
19Run as a module to print the GitHub Actions matrix the container-scan job
20consumes::
22 python3 -m gco.service_images --github-matrix
24The module uses only the standard library so CI can run it with the
25runner's stock interpreter, before any dependency is installed.
26"""
28from __future__ import annotations
30import argparse
31import json
32import re
33import sys
34from dataclasses import asdict, dataclass
35from pathlib import Path
37#: Repository root (``gco/`` lives one level below it).
38REPO_ROOT = Path(__file__).resolve().parents[1]
40#: Directory holding one Dockerfile per in-cluster platform service.
41SERVICE_DOCKERFILE_DIR = "dockerfiles"
43_DOCKERFILE_PREFIX = "Dockerfile."
45#: Image names double as ``gco/<name>`` ECR repository suffixes and CI cache
46#: scopes, so they must be short DNS-style labels (same shape ``cli.images``
47#: accepts for user images).
48_IMAGE_NAME_RE = re.compile(r"^[a-z][a-z0-9-]{0,62}$")
51@dataclass(frozen=True)
52class ImageBuild:
53 """One buildable image: its short name, Dockerfile and build context."""
55 image: str
56 dockerfile: str
57 context: str
60def service_dockerfile(service: str) -> str:
61 """Return the repo-relative Dockerfile path for ``service``.
63 The naming convention lives here and nowhere else:
64 ``dockerfiles/Dockerfile.<service>``.
65 """
66 return f"{SERVICE_DOCKERFILE_DIR}/{_DOCKERFILE_PREFIX}{service}"
69def _validated_name(name: str, source: Path) -> str:
70 """Return ``name`` if it is a usable image name, else raise ``ValueError``.
72 Discovery turns filenames into ECR repository suffixes, image tags and CI
73 cache scopes, so a name that is not a short DNS-style label has to fail
74 here rather than downstream in a registry or a workflow expression.
75 """
76 if not _IMAGE_NAME_RE.match(name):
77 raise ValueError(
78 f"{source}: '{name}' is not a valid image name. Service Dockerfiles must be "
79 f"named {_DOCKERFILE_PREFIX}<name> and container Lambdas must live in "
80 f"lambda/<name>/, where <name> matches {_IMAGE_NAME_RE.pattern} (it becomes the "
81 "ECR repository suffix, the image tag and the CI cache scope)."
82 )
83 return name
86def _image_name(dockerfile: Path) -> str:
87 """Derive the image name from a ``Dockerfile.<name>`` filename, validating it."""
88 return _validated_name(dockerfile.name[len(_DOCKERFILE_PREFIX) :], dockerfile)
91def discover_service_dockerfiles(repo_root: Path = REPO_ROOT) -> dict[str, str]:
92 """Return ``{service: repo-relative Dockerfile path}`` for every shipped service image.
94 Sorted by service name so every consumer sees the same order. Raises
95 ``ValueError`` for a ``Dockerfile.*`` whose suffix is not a valid image
96 name — a stray editor backup in ``dockerfiles/`` should fail loudly, not
97 silently become (or hide) a service.
98 """
99 directory = repo_root / SERVICE_DOCKERFILE_DIR
100 found = sorted(directory.glob(f"{_DOCKERFILE_PREFIX}*"))
101 return {
102 _image_name(path): f"{SERVICE_DOCKERFILE_DIR}/{path.name}"
103 for path in found
104 if path.is_file()
105 }
108def discover_image_builds(repo_root: Path = REPO_ROOT) -> list[ImageBuild]:
109 """Return every image the repository builds, services first.
111 Platform services come from :func:`discover_service_dockerfiles`;
112 container Lambdas are every ``lambda/<name>/Dockerfile`` (skipping the
113 generated ``*-build`` staging directories); root images are every
114 ``Dockerfile.<name>`` at the repository root.
115 """
116 builds = [
117 ImageBuild(image=name, dockerfile=dockerfile, context=".")
118 for name, dockerfile in discover_service_dockerfiles(repo_root).items()
119 ]
120 for dockerfile in sorted((repo_root / "lambda").glob("*/Dockerfile")):
121 lambda_dir = dockerfile.parent
122 if lambda_dir.name.endswith("-build"):
123 continue
124 builds.append(
125 ImageBuild(
126 image=_validated_name(lambda_dir.name, lambda_dir),
127 dockerfile=f"lambda/{lambda_dir.name}/Dockerfile",
128 context=f"lambda/{lambda_dir.name}",
129 )
130 )
131 for dockerfile in sorted(repo_root.glob(f"{_DOCKERFILE_PREFIX}*")):
132 if dockerfile.is_file():
133 builds.append(
134 ImageBuild(image=_image_name(dockerfile), dockerfile=dockerfile.name, context=".")
135 )
136 return builds
139def main(argv: list[str] | None = None) -> int:
140 """CLI entry point: print discovered images as JSON.
142 ``--github-matrix`` emits the ``include`` list for a GitHub Actions job
143 matrix (one object per image with ``image``/``dockerfile``/``context``
144 keys); without it the same list is pretty-printed for humans.
145 """
146 parser = argparse.ArgumentParser(description="List the container images this repo builds.")
147 parser.add_argument(
148 "--github-matrix",
149 action="store_true",
150 help="print a compact JSON list suitable for `fromJSON()` in a workflow matrix",
151 )
152 parser.add_argument(
153 "--repo-root",
154 type=Path,
155 default=REPO_ROOT,
156 help="repository root to scan (default: the checkout this module lives in)",
157 )
158 args = parser.parse_args(argv)
159 rows = [asdict(build) for build in discover_image_builds(args.repo_root)]
160 if not rows:
161 print(f"no Dockerfiles discovered under {args.repo_root}", file=sys.stderr)
162 return 1
163 indent = None if args.github_matrix else 2
164 print(json.dumps(rows, indent=indent))
165 return 0
168if __name__ == "__main__": # pragma: no cover - exercised via subprocess in tests
169 sys.exit(main())