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

1"""Discovery of the container images this repository builds. 

2 

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. 

9 

10Three image families are discovered, each from the tree rather than listed: 

11 

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. 

18 

19Run as a module to print the GitHub Actions matrix the container-scan job 

20consumes:: 

21 

22 python3 -m gco.service_images --github-matrix 

23 

24The module uses only the standard library so CI can run it with the 

25runner's stock interpreter, before any dependency is installed. 

26""" 

27 

28from __future__ import annotations 

29 

30import argparse 

31import json 

32import re 

33import sys 

34from dataclasses import asdict, dataclass 

35from pathlib import Path 

36 

37#: Repository root (``gco/`` lives one level below it). 

38REPO_ROOT = Path(__file__).resolve().parents[1] 

39 

40#: Directory holding one Dockerfile per in-cluster platform service. 

41SERVICE_DOCKERFILE_DIR = "dockerfiles" 

42 

43_DOCKERFILE_PREFIX = "Dockerfile." 

44 

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}$") 

49 

50 

51@dataclass(frozen=True) 

52class ImageBuild: 

53 """One buildable image: its short name, Dockerfile and build context.""" 

54 

55 image: str 

56 dockerfile: str 

57 context: str 

58 

59 

60def service_dockerfile(service: str) -> str: 

61 """Return the repo-relative Dockerfile path for ``service``. 

62 

63 The naming convention lives here and nowhere else: 

64 ``dockerfiles/Dockerfile.<service>``. 

65 """ 

66 return f"{SERVICE_DOCKERFILE_DIR}/{_DOCKERFILE_PREFIX}{service}" 

67 

68 

69def _validated_name(name: str, source: Path) -> str: 

70 """Return ``name`` if it is a usable image name, else raise ``ValueError``. 

71 

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 

84 

85 

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) 

89 

90 

91def discover_service_dockerfiles(repo_root: Path = REPO_ROOT) -> dict[str, str]: 

92 """Return ``{service: repo-relative Dockerfile path}`` for every shipped service image. 

93 

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 } 

106 

107 

108def discover_image_builds(repo_root: Path = REPO_ROOT) -> list[ImageBuild]: 

109 """Return every image the repository builds, services first. 

110 

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 

137 

138 

139def main(argv: list[str] | None = None) -> int: 

140 """CLI entry point: print discovered images as JSON. 

141 

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 

166 

167 

168if __name__ == "__main__": # pragma: no cover - exercised via subprocess in tests 

169 sys.exit(main())