composable-data-stack 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # cli/__init__.py
cli/diagnostics.py ADDED
@@ -0,0 +1,13 @@
1
+ # cli/diagnostics.py
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class Diagnostic:
7
+ level: str # "error" | "warning"
8
+ code: str
9
+ message: str
10
+ path: str
11
+
12
+ def format(self) -> str:
13
+ return f"[{self.code}] {self.path}\n{self.message}"
cli/graph.py ADDED
@@ -0,0 +1,58 @@
1
+ # cli/graph.py
2
+ from __future__ import annotations
3
+
4
+ from .diagnostics import Diagnostic
5
+
6
+
7
+ def validate_dependency_graph(module_ids: set[str], depends_on_map: dict[str, list[str]]) -> list[Diagnostic]:
8
+ diagnostics: list[Diagnostic] = []
9
+
10
+ for module_id, deps in depends_on_map.items():
11
+ for dep in deps:
12
+ if dep not in module_ids:
13
+ diagnostics.append(
14
+ Diagnostic(
15
+ level="error",
16
+ code="E040",
17
+ message=f'Module "{module_id}" depends on unknown module "{dep}".',
18
+ path=f"spec.modules[{module_id}].dependsOn",
19
+ )
20
+ )
21
+ if dep == module_id:
22
+ diagnostics.append(
23
+ Diagnostic(
24
+ level="error",
25
+ code="E040",
26
+ message=f'Module "{module_id}" cannot depend on itself.',
27
+ path=f"spec.modules[{module_id}].dependsOn",
28
+ )
29
+ )
30
+
31
+ visited: set[str] = set()
32
+ visiting: set[str] = set()
33
+
34
+ def dfs(node: str) -> None:
35
+ if node in visiting:
36
+ diagnostics.append(
37
+ Diagnostic(
38
+ level="error",
39
+ code="E040",
40
+ message=f'Dependency cycle detected involving "{node}".',
41
+ path=f"spec.modules[{node}].dependsOn",
42
+ )
43
+ )
44
+ return
45
+ if node in visited:
46
+ return
47
+
48
+ visiting.add(node)
49
+ for dep in depends_on_map.get(node, []):
50
+ if dep in module_ids:
51
+ dfs(dep)
52
+ visiting.remove(node)
53
+ visited.add(node)
54
+
55
+ for module_id in module_ids:
56
+ dfs(module_id)
57
+
58
+ return diagnostics
cli/image_updates.py ADDED
@@ -0,0 +1,326 @@
1
+ # cli/image_updates.py
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.error import HTTPError, URLError
10
+ from urllib.request import Request, urlopen
11
+
12
+ from .loader import load_yaml_file
13
+ from .planner import MaxNestingDepthExceeded, apply_defaults, substitute_string
14
+
15
+ DOCKER_HUB_API = "https://hub.docker.com/v2/repositories"
16
+ SEMVER_PATTERN = re.compile(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+].*)?$")
17
+
18
+
19
+ def _read_max_pages() -> int:
20
+ raw = os.getenv("CDS_DOCKERHUB_MAX_PAGES", "3").strip()
21
+ try:
22
+ value = int(raw)
23
+ except ValueError:
24
+ return 3
25
+ return value if value > 0 else 3
26
+
27
+ def _default_config_context(module_def: dict[str, Any]) -> dict[str, Any] | None:
28
+ """
29
+ Build a ${config.*}-only interpolation context from a module's configSchema
30
+ defaults, so build.dockerfile (and other compose template) expressions that
31
+ reference ${config.*} (e.g. a variant selector) can be statically resolved
32
+ without a profile. Bindings/secrets are intentionally left empty since
33
+ those require profile-time contract resolution this function doesn't have.
34
+ """
35
+ config_schema = module_def.get("spec", {}).get("configSchema")
36
+ if not isinstance(config_schema, dict) or not config_schema:
37
+ return None
38
+ try:
39
+ default_config = apply_defaults({}, config_schema)
40
+ except MaxNestingDepthExceeded:
41
+ return None
42
+ return {"config": default_config, "bindings": {}, "service": {}, "secrets": {}}
43
+
44
+
45
+ def collect_module_images(module_root: Path) -> list[dict[str, Any]]:
46
+ images: list[dict[str, Any]] = []
47
+ for module_file in sorted(module_root.rglob("module.yaml")):
48
+ module_def, diags = load_yaml_file(module_file)
49
+ if module_def is None:
50
+ continue
51
+
52
+ module_name = str(module_file.parent.relative_to(module_root))
53
+ compose = module_def.get("spec", {}).get("implementation", {}).get("compose", {})
54
+ context = _default_config_context(module_def)
55
+ module_images = find_images_in_compose(
56
+ compose, module_dir=module_file.parent, context=context
57
+ )
58
+ # ^^^ pass module_dir so build contexts can be resolved
59
+
60
+ for service_name, image, dockerfile in module_images:
61
+ entry = {"module": module_name, "service": service_name, "image": image}
62
+ if dockerfile:
63
+ entry["dockerfile"] = str(dockerfile)
64
+ images.append(entry)
65
+
66
+ return images
67
+
68
+ def find_images_in_compose(
69
+ compose: Any,
70
+ service_name: str | None = None,
71
+ module_dir: Path | None = None,
72
+ context: dict[str, Any] | None = None,
73
+ ) -> list[tuple[str, str, Path | None]]:
74
+ images: list[tuple[str, str, Path | None]] = []
75
+
76
+ if isinstance(compose, dict):
77
+ if "image" in compose and isinstance(compose["image"], str):
78
+ dockerfile: Path | None = None
79
+
80
+ if "build" in compose and module_dir is not None:
81
+ build = compose["build"]
82
+ if isinstance(build, str):
83
+ # build: ./path (shorthand)
84
+ build_context = module_dir / build
85
+ candidate = build_context / "Dockerfile"
86
+ dockerfile = candidate if candidate.is_file() else None
87
+ elif isinstance(build, dict):
88
+ context_str = build.get("context", ".")
89
+ df_name = build.get("dockerfile", "Dockerfile")
90
+ if context is not None and isinstance(df_name, str):
91
+ # Resolve ${config.*} template expressions (e.g. a
92
+ # variant selector) using configSchema defaults so the
93
+ # default variant's Dockerfile can still be located.
94
+ resolved = substitute_string(df_name, context)
95
+ if isinstance(resolved, str):
96
+ df_name = resolved
97
+ build_context = module_dir / context_str
98
+ candidate = build_context / df_name
99
+ dockerfile = candidate if candidate.is_file() else None
100
+
101
+ images.append((service_name or "<root>", compose["image"], dockerfile))
102
+
103
+ for key, value in compose.items():
104
+ if key == "services" and isinstance(value, dict):
105
+ for svc_name, svc_def in value.items():
106
+ images.extend(
107
+ find_images_in_compose(
108
+ svc_def, service_name=svc_name, module_dir=module_dir, context=context
109
+ )
110
+ )
111
+ elif key != "build": # don't recurse into build blocks
112
+ images.extend(
113
+ find_images_in_compose(
114
+ value, service_name=service_name, module_dir=module_dir, context=context
115
+ )
116
+ )
117
+
118
+ elif isinstance(compose, list):
119
+ for item in compose:
120
+ images.extend(
121
+ find_images_in_compose(
122
+ item, service_name=service_name, module_dir=module_dir, context=context
123
+ )
124
+ )
125
+
126
+ return images
127
+
128
+ _ARG_REF = re.compile(r"\$\{?\w+\}?")
129
+
130
+ def extract_base_image(dockerfile: Path, *, final_stage: bool = True) -> str | None:
131
+ """
132
+ Return the base image from a Dockerfile's FROM instruction.
133
+
134
+ Args:
135
+ dockerfile: Path to the Dockerfile.
136
+ final_stage: If True (default), return the last FROM line's image
137
+ (the runtime stage). If False, return the first.
138
+
139
+ Returns:
140
+ The image reference string, or None if no suitable FROM is found.
141
+ """
142
+ try:
143
+ lines = dockerfile.read_text(encoding="utf-8").splitlines()
144
+ except OSError:
145
+ return None
146
+
147
+ from_images: list[str] = []
148
+
149
+ for line in lines:
150
+ stripped = line.strip()
151
+ # Case-insensitive match; guard against lines with no tokens after FROM
152
+ tokens = stripped.split()
153
+ if not tokens or tokens[0].upper() != "FROM":
154
+ continue
155
+ if len(tokens) < 2:
156
+ continue # malformed: bare FROM with no image
157
+
158
+ image = tokens[1]
159
+
160
+ if image.upper() == "SCRATCH":
161
+ continue # scratch has no base to check
162
+
163
+ if _ARG_REF.search(image):
164
+ continue # skip ARG-substituted references we can't resolve statically
165
+
166
+ from_images.append(image)
167
+
168
+ if not from_images:
169
+ return None
170
+
171
+ return from_images[-1] if final_stage else from_images[0]
172
+
173
+ def parse_image_reference(image: str) -> dict[str, str | None]:
174
+ ref = image.split("@", 1)[0]
175
+ tag = "latest"
176
+ # Fix: only require ":" to be present, not necessarily "/"
177
+ if ":" in ref:
178
+ potential_tag = ref.rsplit(":", 1)[1]
179
+ if "/" not in potential_tag: # colon is a tag separator, not a port
180
+ ref, tag = ref.rsplit(":", 1)
181
+
182
+ parts = ref.split("/")
183
+ if len(parts) == 1:
184
+ registry = "docker.io"
185
+ namespace = "library"
186
+ repository = parts[0]
187
+ elif len(parts) == 2 and "." not in parts[0] and ":" not in parts[0]:
188
+ registry = "docker.io"
189
+ namespace, repository = parts
190
+ elif len(parts) == 2:
191
+ registry = parts[0]
192
+ namespace = None
193
+ repository = parts[1]
194
+ else:
195
+ registry = parts[0]
196
+ namespace = parts[1]
197
+ repository = parts[2] if len(parts) >= 3 else ""
198
+
199
+ return {
200
+ "registry": registry,
201
+ "namespace": namespace,
202
+ "repository": repository,
203
+ "tag": tag,
204
+ }
205
+
206
+
207
+ def is_docker_hub_image(image: str) -> bool:
208
+ info = parse_image_reference(image)
209
+ return info["registry"] in {"docker.io", "registry-1.docker.io"}
210
+
211
+
212
+ def is_local_image(image: str) -> bool:
213
+ return image.endswith(":custom")
214
+
215
+
216
+ def normalize_semver(tag: str) -> str | None:
217
+ clean = tag.split("@", 1)[0].split("-")[0].split("+")[0]
218
+ match = SEMVER_PATTERN.match(clean)
219
+ if not match:
220
+ return None
221
+ major, minor, patch = match.groups()
222
+ components = [major or "0", minor or "0", patch or "0"]
223
+ return ".".join(components)
224
+
225
+
226
+ def semver_key(tag: str) -> tuple[int, int, int] | None:
227
+ normalized = normalize_semver(tag)
228
+ if normalized is None:
229
+ return None
230
+ parts = normalized.split(".")
231
+ return tuple(int(part) for part in parts)
232
+
233
+
234
+ def fetch_dockerhub_tags(namespace: str, repository: str, page_size: int = 100, max_pages: int | None = None) -> list[str] | None:
235
+ tags: list[str] = []
236
+ url = f"{DOCKER_HUB_API}/{namespace}/{repository}/tags?page_size={page_size}"
237
+ pages_read = 0
238
+ page_limit = _read_max_pages() if max_pages is None else max_pages
239
+
240
+ while url and pages_read < page_limit:
241
+ try:
242
+ req = Request(url, headers={"User-Agent": "cds-image-check/1.0"})
243
+ # url is always http(s), built from the hardcoded DOCKER_HUB_API
244
+ # constant or the scheme-validated pagination url below.
245
+ with urlopen(req, timeout=10) as response: # nosec B310
246
+ data = json.loads(response.read().decode())
247
+ except (HTTPError, URLError, ValueError):
248
+ return None
249
+
250
+ pages_read += 1
251
+
252
+ for result in data.get("results", []):
253
+ name = result.get("name")
254
+ if isinstance(name, str):
255
+ tags.append(name)
256
+
257
+ next_url = data.get("next")
258
+ url = next_url if isinstance(next_url, str) and next_url.startswith(("http://", "https://")) else None
259
+ if not url:
260
+ break
261
+
262
+ return tags
263
+
264
+
265
+ def find_newer_tag(current_tag: str, tags: list[str]) -> str | None:
266
+ current_semver = semver_key(current_tag)
267
+ if current_semver is None:
268
+ return None
269
+
270
+ current_parts = current_tag.split("-")[0].split("+")[0].split(".")
271
+ current_len = len(current_parts)
272
+ candidate_tags: dict[tuple[int, int, int], str] = {}
273
+
274
+ for tag in tags:
275
+ candidate_semver = semver_key(tag)
276
+ if candidate_semver is None:
277
+ continue
278
+
279
+ if current_len == 1:
280
+ if candidate_semver[0] != current_semver[0]:
281
+ continue
282
+ elif current_len == 2:
283
+ if candidate_semver[:2] != current_semver[:2]:
284
+ continue
285
+ else:
286
+ if candidate_semver[:2] != current_semver[:2]:
287
+ continue
288
+
289
+ candidate_tags[candidate_semver] = tag
290
+
291
+ if not candidate_tags:
292
+ return None
293
+
294
+ latest = max(candidate_tags)
295
+ if latest > current_semver:
296
+ return candidate_tags[latest]
297
+ return None
298
+
299
+ def check_image_update(image: str, dockerfile: Path | str | None = None) -> dict[str, Any]:
300
+ info = parse_image_reference(image)
301
+ if is_local_image(image) or dockerfile is not None:
302
+ if dockerfile is None:
303
+ return {"image": image, "status": "local", "latest": None}
304
+ # Resolve FROM line and recurse on the base image
305
+ base_image = extract_base_image(Path(dockerfile))
306
+ if base_image is None:
307
+ return {"image": image, "status": "local-no-base", "latest": None}
308
+ result = check_image_update(base_image)
309
+ return {**result, "image": image, "base_image": base_image}
310
+
311
+ if not is_docker_hub_image(image):
312
+ return {"image": image, "status": "unsupported-registry", "latest": None}
313
+
314
+ namespace = info["namespace"]
315
+ repository = info["repository"]
316
+ if not repository:
317
+ return {"image": image, "status": "invalid", "latest": None}
318
+
319
+ tags = fetch_dockerhub_tags(namespace, repository)
320
+ if tags is None:
321
+ return {"image": image, "status": "lookup-failed", "latest": None}
322
+
323
+ latest = find_newer_tag(info["tag"], tags)
324
+ if latest:
325
+ return {"image": image, "status": "update-available", "latest": latest}
326
+ return {"image": image, "status": "up-to-date", "latest": None}