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 +1 -0
- cli/diagnostics.py +13 -0
- cli/graph.py +58 -0
- cli/image_updates.py +326 -0
- cli/image_verification.py +484 -0
- cli/loader.py +180 -0
- cli/main.py +1656 -0
- cli/overlay.py +239 -0
- cli/planner.py +618 -0
- cli/preflight.py +418 -0
- cli/renderer.py +791 -0
- cli/resolver.py +28 -0
- cli/resources/__init__.py +1 -0
- cli/resources/rule-schema.json +274 -0
- cli/resources/rule-set.json +919 -0
- cli/secrets.py +169 -0
- cli/security.py +768 -0
- cli/security_common.py +41 -0
- cli/state.py +112 -0
- cli/up_runner.py +257 -0
- cli/validator.py +570 -0
- composable_data_stack-0.4.0.dist-info/METADATA +872 -0
- composable_data_stack-0.4.0.dist-info/RECORD +27 -0
- composable_data_stack-0.4.0.dist-info/WHEEL +5 -0
- composable_data_stack-0.4.0.dist-info/entry_points.txt +2 -0
- composable_data_stack-0.4.0.dist-info/licenses/LICENSE +201 -0
- composable_data_stack-0.4.0.dist-info/top_level.txt +1 -0
cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# cli/__init__.py
|
cli/diagnostics.py
ADDED
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}
|