tasktree 0.0.6__py3-none-any.whl → 0.0.8__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.
- tasktree/cli.py +24 -16
- tasktree/docker.py +438 -0
- tasktree/executor.py +526 -50
- tasktree/graph.py +30 -1
- tasktree/hasher.py +63 -2
- tasktree/parser.py +1099 -32
- tasktree/state.py +1 -1
- tasktree/substitution.py +195 -0
- tasktree/types.py +11 -2
- tasktree-0.0.8.dist-info/METADATA +1149 -0
- tasktree-0.0.8.dist-info/RECORD +15 -0
- tasktree-0.0.6.dist-info/METADATA +0 -699
- tasktree-0.0.6.dist-info/RECORD +0 -13
- {tasktree-0.0.6.dist-info → tasktree-0.0.8.dist-info}/WHEEL +0 -0
- {tasktree-0.0.6.dist-info → tasktree-0.0.8.dist-info}/entry_points.txt +0 -0
tasktree/cli.py
CHANGED
|
@@ -375,8 +375,8 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
|
|
|
375
375
|
|
|
376
376
|
parsed_specs = []
|
|
377
377
|
for spec in arg_specs:
|
|
378
|
-
|
|
379
|
-
parsed_specs.append(
|
|
378
|
+
parsed = parse_arg_spec(spec)
|
|
379
|
+
parsed_specs.append(parsed)
|
|
380
380
|
|
|
381
381
|
args_dict = {}
|
|
382
382
|
positional_index = 0
|
|
@@ -386,41 +386,49 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
|
|
|
386
386
|
if "=" in value_str:
|
|
387
387
|
arg_name, arg_value = value_str.split("=", 1)
|
|
388
388
|
# Find the spec for this argument
|
|
389
|
-
spec = next((s for s in parsed_specs if s
|
|
389
|
+
spec = next((s for s in parsed_specs if s.name == arg_name), None)
|
|
390
390
|
if spec is None:
|
|
391
391
|
console.print(f"[red]Unknown argument: {arg_name}[/red]")
|
|
392
392
|
raise typer.Exit(1)
|
|
393
|
-
name, arg_type, default = spec
|
|
394
393
|
else:
|
|
395
394
|
# Positional argument
|
|
396
395
|
if positional_index >= len(parsed_specs):
|
|
397
396
|
console.print(f"[red]Too many arguments[/red]")
|
|
398
397
|
raise typer.Exit(1)
|
|
399
|
-
|
|
398
|
+
spec = parsed_specs[positional_index]
|
|
400
399
|
arg_value = value_str
|
|
401
400
|
positional_index += 1
|
|
402
401
|
|
|
403
|
-
# Convert value to appropriate type
|
|
402
|
+
# Convert value to appropriate type (exported args are always strings)
|
|
404
403
|
try:
|
|
405
|
-
click_type = get_click_type(arg_type)
|
|
404
|
+
click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
|
|
406
405
|
converted_value = click_type.convert(arg_value, None, None)
|
|
407
|
-
|
|
406
|
+
|
|
407
|
+
# Validate choices after type conversion
|
|
408
|
+
if spec.choices is not None and converted_value not in spec.choices:
|
|
409
|
+
console.print(f"[red]Invalid value for {spec.name}: {converted_value!r}[/red]")
|
|
410
|
+
console.print(f"Valid choices: {', '.join(repr(c) for c in spec.choices)}")
|
|
411
|
+
raise typer.Exit(1)
|
|
412
|
+
|
|
413
|
+
args_dict[spec.name] = converted_value
|
|
414
|
+
except typer.Exit:
|
|
415
|
+
raise # Re-raise typer.Exit without wrapping
|
|
408
416
|
except Exception as e:
|
|
409
|
-
console.print(f"[red]Invalid value for {name}: {e}[/red]")
|
|
417
|
+
console.print(f"[red]Invalid value for {spec.name}: {e}[/red]")
|
|
410
418
|
raise typer.Exit(1)
|
|
411
419
|
|
|
412
420
|
# Fill in defaults for missing arguments
|
|
413
|
-
for
|
|
414
|
-
if name not in args_dict:
|
|
415
|
-
if default is not None:
|
|
421
|
+
for spec in parsed_specs:
|
|
422
|
+
if spec.name not in args_dict:
|
|
423
|
+
if spec.default is not None:
|
|
416
424
|
try:
|
|
417
|
-
click_type = get_click_type(arg_type)
|
|
418
|
-
args_dict[name] = click_type.convert(default, None, None)
|
|
425
|
+
click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
|
|
426
|
+
args_dict[spec.name] = click_type.convert(spec.default, None, None)
|
|
419
427
|
except Exception as e:
|
|
420
|
-
console.print(f"[red]Invalid default value for {name}: {e}[/red]")
|
|
428
|
+
console.print(f"[red]Invalid default value for {spec.name}: {e}[/red]")
|
|
421
429
|
raise typer.Exit(1)
|
|
422
430
|
else:
|
|
423
|
-
console.print(f"[red]Missing required argument: {name}[/red]")
|
|
431
|
+
console.print(f"[red]Missing required argument: {spec.name}[/red]")
|
|
424
432
|
raise typer.Exit(1)
|
|
425
433
|
|
|
426
434
|
return args_dict
|
tasktree/docker.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Docker integration for Task Tree.
|
|
2
|
+
|
|
3
|
+
Provides Docker image building and container execution capabilities.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import pathspec
|
|
18
|
+
except ImportError:
|
|
19
|
+
pathspec = None # type: ignore
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from tasktree.parser import Environment
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DockerError(Exception):
|
|
26
|
+
"""Raised when Docker operations fail."""
|
|
27
|
+
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DockerManager:
|
|
32
|
+
"""Manages Docker image building and container execution."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, project_root: Path):
|
|
35
|
+
"""Initialize Docker manager.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
project_root: Root directory of the project (where tasktree.yaml is located)
|
|
39
|
+
"""
|
|
40
|
+
self._project_root = project_root
|
|
41
|
+
self._built_images: dict[str, tuple[str, str]] = {} # env_name -> (image_tag, image_id) cache
|
|
42
|
+
|
|
43
|
+
def _should_add_user_flag(self) -> bool:
|
|
44
|
+
"""Check if --user flag should be added to docker run.
|
|
45
|
+
|
|
46
|
+
Returns False on Windows (where Docker Desktop handles UID mapping automatically).
|
|
47
|
+
Returns True on Linux/macOS where os.getuid() and os.getgid() are available.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
True if --user flag should be added, False otherwise
|
|
51
|
+
"""
|
|
52
|
+
# Skip on Windows - Docker Desktop handles UID mapping differently
|
|
53
|
+
if platform.system() == "Windows":
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
# Check if os.getuid() and os.getgid() are available (Linux/macOS)
|
|
57
|
+
return hasattr(os, "getuid") and hasattr(os, "getgid")
|
|
58
|
+
|
|
59
|
+
def ensure_image_built(self, env: Environment) -> tuple[str, str]:
|
|
60
|
+
"""Build Docker image if not already built this invocation.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
env: Environment definition with dockerfile and context
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Tuple of (image_tag, image_id)
|
|
67
|
+
- image_tag: Tag like "tt-env-builder"
|
|
68
|
+
- image_id: Full image ID like "sha256:abc123..."
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
DockerError: If docker command not available or build fails
|
|
72
|
+
"""
|
|
73
|
+
# Check if already built this invocation
|
|
74
|
+
if env.name in self._built_images:
|
|
75
|
+
tag, image_id = self._built_images[env.name]
|
|
76
|
+
return tag, image_id
|
|
77
|
+
|
|
78
|
+
# Check if docker is available
|
|
79
|
+
self._check_docker_available()
|
|
80
|
+
|
|
81
|
+
# Resolve paths
|
|
82
|
+
dockerfile_path = self._project_root / env.dockerfile
|
|
83
|
+
context_path = self._project_root / env.context
|
|
84
|
+
|
|
85
|
+
# Generate image tag
|
|
86
|
+
image_tag = f"tt-env-{env.name}"
|
|
87
|
+
|
|
88
|
+
# Build the image
|
|
89
|
+
try:
|
|
90
|
+
subprocess.run(
|
|
91
|
+
[
|
|
92
|
+
"docker",
|
|
93
|
+
"build",
|
|
94
|
+
"-t",
|
|
95
|
+
image_tag,
|
|
96
|
+
"-f",
|
|
97
|
+
str(dockerfile_path),
|
|
98
|
+
str(context_path),
|
|
99
|
+
],
|
|
100
|
+
check=True,
|
|
101
|
+
capture_output=False, # Show build output to user
|
|
102
|
+
)
|
|
103
|
+
except subprocess.CalledProcessError as e:
|
|
104
|
+
raise DockerError(
|
|
105
|
+
f"Failed to build Docker image for environment '{env.name}': "
|
|
106
|
+
f"docker build exited with code {e.returncode}"
|
|
107
|
+
) from e
|
|
108
|
+
except FileNotFoundError:
|
|
109
|
+
raise DockerError(
|
|
110
|
+
"Docker command not found. Please install Docker and ensure it's in your PATH."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Get the image ID
|
|
114
|
+
image_id = self._get_image_id(image_tag)
|
|
115
|
+
|
|
116
|
+
# Cache both tag and ID
|
|
117
|
+
self._built_images[env.name] = (image_tag, image_id)
|
|
118
|
+
return image_tag, image_id
|
|
119
|
+
|
|
120
|
+
def run_in_container(
|
|
121
|
+
self,
|
|
122
|
+
env: Environment,
|
|
123
|
+
cmd: str,
|
|
124
|
+
working_dir: Path,
|
|
125
|
+
container_working_dir: str,
|
|
126
|
+
) -> subprocess.CompletedProcess:
|
|
127
|
+
"""Execute command inside Docker container.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
env: Environment definition
|
|
131
|
+
cmd: Command to execute
|
|
132
|
+
working_dir: Host working directory (for resolving relative volume paths)
|
|
133
|
+
container_working_dir: Working directory inside container
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
CompletedProcess from subprocess.run
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
DockerError: If docker run fails
|
|
140
|
+
"""
|
|
141
|
+
# Ensure image is built (returns tag and ID)
|
|
142
|
+
image_tag, image_id = self.ensure_image_built(env)
|
|
143
|
+
|
|
144
|
+
# Build docker run command
|
|
145
|
+
docker_cmd = ["docker", "run", "--rm"]
|
|
146
|
+
|
|
147
|
+
# Add user mapping (run as current host user) unless explicitly disabled or on Windows
|
|
148
|
+
if not env.run_as_root and self._should_add_user_flag():
|
|
149
|
+
uid = os.getuid()
|
|
150
|
+
gid = os.getgid()
|
|
151
|
+
docker_cmd.extend(["--user", f"{uid}:{gid}"])
|
|
152
|
+
|
|
153
|
+
docker_cmd.extend(env.extra_args)
|
|
154
|
+
|
|
155
|
+
# Add volume mounts
|
|
156
|
+
for volume in env.volumes:
|
|
157
|
+
# Resolve volume paths
|
|
158
|
+
resolved_volume = self._resolve_volume_mount(volume)
|
|
159
|
+
docker_cmd.extend(["-v", resolved_volume])
|
|
160
|
+
|
|
161
|
+
# Add port mappings
|
|
162
|
+
for port in env.ports:
|
|
163
|
+
docker_cmd.extend(["-p", port])
|
|
164
|
+
|
|
165
|
+
# Add environment variables
|
|
166
|
+
for var_name, var_value in env.env_vars.items():
|
|
167
|
+
docker_cmd.extend(["-e", f"{var_name}={var_value}"])
|
|
168
|
+
|
|
169
|
+
# Add working directory
|
|
170
|
+
if container_working_dir:
|
|
171
|
+
docker_cmd.extend(["-w", container_working_dir])
|
|
172
|
+
|
|
173
|
+
# Add image tag
|
|
174
|
+
docker_cmd.append(image_tag)
|
|
175
|
+
|
|
176
|
+
# Add shell and command
|
|
177
|
+
shell = env.shell or "sh"
|
|
178
|
+
docker_cmd.extend([shell, "-c", cmd])
|
|
179
|
+
|
|
180
|
+
# Execute
|
|
181
|
+
try:
|
|
182
|
+
result = subprocess.run(
|
|
183
|
+
docker_cmd,
|
|
184
|
+
cwd=working_dir,
|
|
185
|
+
check=True,
|
|
186
|
+
capture_output=False, # Stream output to terminal
|
|
187
|
+
)
|
|
188
|
+
return result
|
|
189
|
+
except subprocess.CalledProcessError as e:
|
|
190
|
+
raise DockerError(
|
|
191
|
+
f"Docker container execution failed with exit code {e.returncode}"
|
|
192
|
+
) from e
|
|
193
|
+
|
|
194
|
+
def _resolve_volume_mount(self, volume: str) -> str:
|
|
195
|
+
"""Resolve volume mount specification.
|
|
196
|
+
|
|
197
|
+
Handles:
|
|
198
|
+
- Relative paths (resolved relative to project_root)
|
|
199
|
+
- Home directory expansion (~)
|
|
200
|
+
- Absolute paths (used as-is)
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
volume: Volume specification (e.g., "./src:/workspace/src" or "~/.cargo:/root/.cargo")
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Resolved volume specification with absolute host path
|
|
207
|
+
"""
|
|
208
|
+
if ":" not in volume:
|
|
209
|
+
raise ValueError(
|
|
210
|
+
f"Invalid volume specification: '{volume}'. "
|
|
211
|
+
f"Format should be 'host_path:container_path'"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
host_path, container_path = volume.split(":", 1)
|
|
215
|
+
|
|
216
|
+
# Expand home directory
|
|
217
|
+
if host_path.startswith("~"):
|
|
218
|
+
host_path = os.path.expanduser(host_path)
|
|
219
|
+
resolved_host_path = Path(host_path)
|
|
220
|
+
# Resolve relative paths
|
|
221
|
+
elif not Path(host_path).is_absolute():
|
|
222
|
+
resolved_host_path = self._project_root / host_path
|
|
223
|
+
# Absolute paths used as-is
|
|
224
|
+
else:
|
|
225
|
+
resolved_host_path = Path(host_path)
|
|
226
|
+
|
|
227
|
+
return f"{resolved_host_path}:{container_path}"
|
|
228
|
+
|
|
229
|
+
def _check_docker_available(self) -> None:
|
|
230
|
+
"""Check if docker command is available.
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
DockerError: If docker is not available
|
|
234
|
+
"""
|
|
235
|
+
try:
|
|
236
|
+
subprocess.run(
|
|
237
|
+
["docker", "--version"],
|
|
238
|
+
check=True,
|
|
239
|
+
capture_output=True,
|
|
240
|
+
text=True,
|
|
241
|
+
)
|
|
242
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
243
|
+
raise DockerError(
|
|
244
|
+
"Docker is not available. Please install Docker and ensure it's running.\n"
|
|
245
|
+
"Visit https://docs.docker.com/get-docker/ for installation instructions."
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def _get_image_id(self, image_tag: str) -> str:
|
|
249
|
+
"""Get the full image ID for a given tag.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
image_tag: Docker image tag (e.g., "tt-env-builder")
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Full image ID (e.g., "sha256:abc123def456...")
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
DockerError: If cannot inspect image
|
|
259
|
+
"""
|
|
260
|
+
try:
|
|
261
|
+
result = subprocess.run(
|
|
262
|
+
["docker", "inspect", "--format={{.Id}}", image_tag],
|
|
263
|
+
check=True,
|
|
264
|
+
capture_output=True,
|
|
265
|
+
text=True,
|
|
266
|
+
)
|
|
267
|
+
image_id = result.stdout.strip()
|
|
268
|
+
return image_id
|
|
269
|
+
except subprocess.CalledProcessError as e:
|
|
270
|
+
raise DockerError(f"Failed to inspect image {image_tag}: {e.stderr}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def is_docker_environment(env: Environment) -> bool:
|
|
274
|
+
"""Check if environment is Docker-based.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
env: Environment to check
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
True if environment has a dockerfile field, False otherwise
|
|
281
|
+
"""
|
|
282
|
+
return bool(env.dockerfile)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def resolve_container_working_dir(
|
|
286
|
+
env_working_dir: str, task_working_dir: str
|
|
287
|
+
) -> str:
|
|
288
|
+
"""Resolve working directory inside container.
|
|
289
|
+
|
|
290
|
+
Combines environment's working_dir with task's working_dir:
|
|
291
|
+
- If task specifies working_dir: container_dir = env_working_dir / task_working_dir
|
|
292
|
+
- If task doesn't specify: container_dir = env_working_dir
|
|
293
|
+
- If neither specify: container_dir = "/" (Docker default)
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
env_working_dir: Working directory from environment definition
|
|
297
|
+
task_working_dir: Working directory from task definition
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
Resolved working directory path
|
|
301
|
+
"""
|
|
302
|
+
if not env_working_dir and not task_working_dir:
|
|
303
|
+
return "/"
|
|
304
|
+
|
|
305
|
+
if not task_working_dir:
|
|
306
|
+
return env_working_dir
|
|
307
|
+
|
|
308
|
+
# Combine paths
|
|
309
|
+
if env_working_dir:
|
|
310
|
+
# Join paths using POSIX separator (works inside Linux containers)
|
|
311
|
+
return f"{env_working_dir.rstrip('/')}/{task_working_dir.lstrip('/')}"
|
|
312
|
+
else:
|
|
313
|
+
return f"/{task_working_dir.lstrip('/')}"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def parse_dockerignore(dockerignore_path: Path) -> "pathspec.PathSpec | None":
|
|
317
|
+
"""Parse .dockerignore file into pathspec matcher.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
dockerignore_path: Path to .dockerignore file
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
PathSpec object for matching, or None if file doesn't exist or pathspec not available
|
|
324
|
+
"""
|
|
325
|
+
if pathspec is None:
|
|
326
|
+
# pathspec library not available - can't parse .dockerignore
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
if not dockerignore_path.exists():
|
|
330
|
+
return pathspec.PathSpec([]) # Empty matcher
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
with open(dockerignore_path, "r") as f:
|
|
334
|
+
spec = pathspec.PathSpec.from_lines("gitwildmatch", f)
|
|
335
|
+
return spec
|
|
336
|
+
except Exception:
|
|
337
|
+
# Invalid patterns - return empty matcher rather than failing
|
|
338
|
+
return pathspec.PathSpec([])
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def context_changed_since(
|
|
342
|
+
context_path: Path,
|
|
343
|
+
dockerignore_path: Path | None,
|
|
344
|
+
last_run_time: float,
|
|
345
|
+
) -> bool:
|
|
346
|
+
"""Check if any file in Docker build context has changed since last run.
|
|
347
|
+
|
|
348
|
+
Uses early-exit optimization: stops on first changed file found.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
context_path: Path to Docker build context directory
|
|
352
|
+
dockerignore_path: Optional path to .dockerignore file
|
|
353
|
+
last_run_time: Unix timestamp of last task run
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
True if any file changed, False otherwise
|
|
357
|
+
"""
|
|
358
|
+
# Parse .dockerignore
|
|
359
|
+
dockerignore_spec = None
|
|
360
|
+
if dockerignore_path:
|
|
361
|
+
dockerignore_spec = parse_dockerignore(dockerignore_path)
|
|
362
|
+
|
|
363
|
+
# Walk context directory
|
|
364
|
+
for file_path in context_path.rglob("*"):
|
|
365
|
+
if not file_path.is_file():
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
# Check if file matches .dockerignore patterns
|
|
369
|
+
if dockerignore_spec:
|
|
370
|
+
try:
|
|
371
|
+
relative_path = file_path.relative_to(context_path)
|
|
372
|
+
if dockerignore_spec.match_file(str(relative_path)):
|
|
373
|
+
continue # Skip ignored files
|
|
374
|
+
except ValueError:
|
|
375
|
+
# File not relative to context (shouldn't happen with rglob)
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
# Check if file changed (early exit)
|
|
379
|
+
try:
|
|
380
|
+
if file_path.stat().st_mtime > last_run_time:
|
|
381
|
+
return True # Found a changed file
|
|
382
|
+
except (OSError, FileNotFoundError):
|
|
383
|
+
# File might have been deleted - consider it changed
|
|
384
|
+
return True
|
|
385
|
+
|
|
386
|
+
return False # No changes found
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def extract_from_images(dockerfile_content: str) -> list[tuple[str, str | None]]:
|
|
390
|
+
"""Extract image references from FROM lines in Dockerfile.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
dockerfile_content: Content of Dockerfile
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
List of (image_reference, digest) tuples where digest may be None for unpinned images
|
|
397
|
+
Example: [("rust:1.75", None), ("rust", "sha256:abc123...")]
|
|
398
|
+
"""
|
|
399
|
+
# Regex pattern to match FROM lines
|
|
400
|
+
# Handles: FROM [--platform=...] image[:tag][@digest] [AS alias]
|
|
401
|
+
from_pattern = re.compile(
|
|
402
|
+
r"^\s*FROM\s+" # FROM keyword
|
|
403
|
+
r"(?:--platform=[^\s]+\s+)?" # Optional platform flag
|
|
404
|
+
r"([^\s@]+)" # Image name (possibly with :tag)
|
|
405
|
+
r"(?:@(sha256:[a-f0-9]+))?" # Optional @digest
|
|
406
|
+
r"(?:\s+AS\s+\w+)?" # Optional AS alias
|
|
407
|
+
r"\s*$",
|
|
408
|
+
re.MULTILINE | re.IGNORECASE,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
matches = from_pattern.findall(dockerfile_content)
|
|
412
|
+
return [(image, digest if digest else None) for image, digest in matches]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def check_unpinned_images(dockerfile_content: str) -> list[str]:
|
|
416
|
+
"""Check for unpinned base images in Dockerfile.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
dockerfile_content: Content of Dockerfile
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
List of unpinned image references (images without @sha256:... digests)
|
|
423
|
+
"""
|
|
424
|
+
images = extract_from_images(dockerfile_content)
|
|
425
|
+
return [image for image, digest in images if digest is None]
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def parse_base_image_digests(dockerfile_content: str) -> list[str]:
|
|
429
|
+
"""Parse pinned base image digests from Dockerfile.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
dockerfile_content: Content of Dockerfile
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
List of digests (e.g., ["sha256:abc123...", "sha256:def456..."])
|
|
436
|
+
"""
|
|
437
|
+
images = extract_from_images(dockerfile_content)
|
|
438
|
+
return [digest for _image, digest in images if digest is not None]
|