Skip to content

Commit aa7c235

Browse files
committed
Address review findings in mirror_images.py
- Fix silent except-pass when parsing existing lock file (now warns) - Add error handling for future.result() in cmd_mirror check phase - Remove dead code in resolve_digest (overwritten cmd variable) - Move hashlib/fnmatch/glob imports to top level - Add docker as copy tool via buildx imagetools create - Log failures in list_tags instead of silently returning empty list - Wrap json.loads in try-except for skopeo tag listing - Validate YAML structure in parse_mirror_yaml and parse_lock_yaml - Validate input in cmd_add for empty/malformed YAML - Replace subprocess find with glob.glob in lint scanners - Add _read_lines helper with error handling for file reads in lint - Simplify redundant default expression in parse_mirror_yaml
1 parent 04ba038 commit aa7c235

1 file changed

Lines changed: 93 additions & 64 deletions

File tree

bin/mirror_images.py

Lines changed: 93 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
"""
1212

1313
import argparse
14+
import fnmatch
15+
import glob
16+
import hashlib
1417
import json
1518
import os
1619
import re
@@ -53,10 +56,11 @@ def _find_digest_tool() -> str:
5356

5457
def _find_copy_tool() -> str:
5558
"""Find a tool that can copy images between registries."""
56-
tool = _find_tool(["crane", "skopeo"])
59+
tool = _find_tool(["crane", "skopeo", "docker"])
5760
if not tool:
58-
print("No image copy tool found. Install one of: crane, skopeo",
59-
file=sys.stderr)
61+
print(
62+
"No image copy tool found. Install one of: crane, skopeo, docker",
63+
file=sys.stderr)
6064
sys.exit(1)
6165
return tool
6266

@@ -71,10 +75,6 @@ def resolve_digest(image_ref: str, tool: str) -> str:
7175
f"docker://{image_ref}"
7276
]
7377
elif tool in ("docker", "podman", "nerdctl"):
74-
cmd = [tool, "buildx", "imagetools", "inspect", "--format",
75-
"{{.Manifest.Digest}}", image_ref] if tool == "docker" else \
76-
[tool, "image", "inspect", "--format", "{{.Digest}}", image_ref]
77-
# Simpler: use manifest inspect and parse JSON
7878
cmd = [tool, "manifest", "inspect", image_ref]
7979
else:
8080
raise ValueError(f"Unknown tool: {tool}")
@@ -87,9 +87,9 @@ def resolve_digest(image_ref: str, tool: str) -> str:
8787
if tool in ("crane", "skopeo"):
8888
return result.stdout.strip()
8989

90-
# docker/podman/nerdctl: parse manifest JSON for the digest header
91-
# The digest is in the descriptor, but we can compute it from the raw manifest
92-
import hashlib
90+
# docker/podman/nerdctl: compute digest from raw manifest output.
91+
# Note: this may differ from the registry's canonical digest if the
92+
# tool reformats the JSON. Prefer crane or skopeo for accurate digests.
9393
raw = result.stdout.strip().encode()
9494
return f"sha256:{hashlib.sha256(raw).hexdigest()}"
9595

@@ -131,6 +131,10 @@ def copy_image(source_ref: str, target: str, tool: str) -> tuple[bool, str]:
131131
tool, "copy", "--all", f"docker://{source_ref}",
132132
f"docker://{target}"
133133
]
134+
elif tool == "docker":
135+
cmd = [
136+
tool, "buildx", "imagetools", "create", "-t", target, source_ref
137+
]
134138
else:
135139
raise ValueError(f"Unknown copy tool: {tool}")
136140

@@ -142,25 +146,39 @@ def copy_image(source_ref: str, target: str, tool: str) -> tuple[bool, str]:
142146

143147
def list_tags(image_repo: str, tool: str) -> list[str]:
144148
"""List all tags for a repository."""
149+
if tool in ("docker", "podman", "nerdctl"):
150+
return []
151+
145152
if tool == "crane":
146153
result = subprocess.run(["crane", "ls", image_repo],
147154
capture_output=True,
148155
text=True)
149-
if result.returncode == 0:
150-
return result.stdout.strip().splitlines()
151156
elif tool == "skopeo":
152157
result = subprocess.run(
153158
["skopeo", "list-tags", f"docker://{image_repo}"],
154159
capture_output=True,
155160
text=True,
156161
)
157-
if result.returncode == 0:
158-
data = json.loads(result.stdout)
159-
return data.get("Tags", [])
160-
elif tool in ("docker", "podman", "nerdctl"):
161-
# No good way to list tags with docker CLI; skip tag resolution
162+
else:
163+
return []
164+
165+
if result.returncode != 0:
166+
print(
167+
f" [warning] Failed to list tags for {image_repo}: "
168+
f"{result.stderr.strip()}",
169+
file=sys.stderr)
170+
return []
171+
172+
if tool == "crane":
173+
return result.stdout.strip().splitlines()
174+
175+
try:
176+
data = json.loads(result.stdout)
177+
return data.get("Tags", [])
178+
except json.JSONDecodeError as exc:
179+
print(f" [warning] Failed to parse tags for {image_repo}: {exc}",
180+
file=sys.stderr)
162181
return []
163-
return []
164182

165183

166184
# ---------------------------------------------------------------------------
@@ -193,14 +211,17 @@ def parse_mirror_yaml(path: str) -> dict[str, str]:
193211
with open(path) as f:
194212
entries = yaml.safe_load(f)
195213

214+
if not isinstance(entries, list):
215+
raise ValueError(f"Expected a YAML list in {path}")
216+
196217
images = {}
197218
for entry in entries:
198219
if isinstance(entry, str):
199220
images[entry] = f"{DEST_REGISTRY}/{entry}"
200221
elif isinstance(entry, dict):
201222
for source, opts in entry.items():
202-
target = opts.get("target", f"{DEST_REGISTRY}/{source}"
203-
) if opts else f"{DEST_REGISTRY}/{source}"
223+
opts = opts or {}
224+
target = opts.get("target", f"{DEST_REGISTRY}/{source}")
204225
images[source] = target
205226
return images
206227

@@ -209,6 +230,10 @@ def parse_lock_yaml(path: str) -> dict[str, dict]:
209230
"""Parse mirror_images.lock.yaml. Returns {source: {digest, target, tag}}."""
210231
with open(path) as f:
211232
data = yaml.safe_load(f)
233+
if not isinstance(data, dict) or "images" not in data:
234+
raise ValueError(
235+
f"Malformed lock file {path}: expected a YAML mapping with an 'images' key"
236+
)
212237
return data["images"]
213238

214239

@@ -262,7 +287,9 @@ def _check(tag: str):
262287
try:
263288
d = resolve_digest(f"{repo}:{tag}", tool)
264289
return tag, d
265-
except Exception:
290+
except Exception as exc:
291+
print(f" [debug] Could not resolve {repo}:{tag}: {exc}",
292+
file=sys.stderr)
266293
return tag, None
267294

268295
futures = {pool.submit(_check, tag): tag for tag in candidates}
@@ -317,8 +344,11 @@ def cmd_lock(args: argparse.Namespace) -> int:
317344
existing = parse_lock_yaml(output_path)
318345
results.update(existing)
319346
print(f"Loaded {len(results)} existing entries from {output_path}")
320-
except Exception:
321-
pass
347+
except Exception as exc:
348+
print(
349+
f"WARNING: Could not parse existing lock file {output_path}: {exc}",
350+
file=sys.stderr)
351+
print("Resolving all images from scratch.", file=sys.stderr)
322352

323353
# Only resolve images not already in the lock file
324354
to_resolve = {
@@ -427,7 +457,13 @@ def _check(source: str, info: dict):
427457
for src, info in images.items()
428458
}
429459
for future in as_completed(futures):
430-
source, info, present = future.result()
460+
src = futures[future]
461+
try:
462+
source, info, present = future.result()
463+
except Exception as exc:
464+
print(f" ERROR checking {src}: {exc}", file=sys.stderr)
465+
to_copy.append((src, images[src]))
466+
continue
431467
if present:
432468
already_present += 1
433469
print(f" = {source} (already mirrored)")
@@ -492,56 +528,46 @@ def _is_external(img: str) -> bool:
492528
return False
493529
return True
494530

531+
def _read_lines(filepath):
532+
try:
533+
with open(filepath) as f:
534+
return list(enumerate(f, 1))
535+
except OSError as exc:
536+
print(f" [warning] Could not read {filepath}: {exc}",
537+
file=sys.stderr)
538+
return []
539+
495540
def _scan_dockerfiles():
496-
result = subprocess.run(
497-
[
498-
"find", PROJECT_DIR, "-name", "Dockerfile*", "-not", "-path",
499-
"*/.git/*"
500-
],
501-
capture_output=True,
502-
text=True,
503-
)
504-
for filepath in result.stdout.splitlines():
505-
if not filepath.strip():
541+
for filepath in glob.glob(os.path.join(PROJECT_DIR, "**/Dockerfile*"),
542+
recursive=True):
543+
if "/.git/" in filepath:
506544
continue
507-
with open(filepath) as f:
508-
for lineno, line in enumerate(f, 1):
509-
stripped = line.strip()
510-
m = re.match(r"^FROM\s+(\S+)", stripped, re.IGNORECASE)
511-
if m and _is_external(m.group(1)):
512-
hits.append((filepath, lineno, stripped, m.group(1)))
513-
for m in re.finditer(r"--from=(\S+)", stripped):
514-
img = m.group(1)
515-
if ("/" in img or ":" in img) and _is_external(img):
516-
hits.append((filepath, lineno, stripped, img))
545+
for lineno, line in _read_lines(filepath):
546+
stripped = line.strip()
547+
m = re.match(r"^FROM\s+(\S+)", stripped, re.IGNORECASE)
548+
if m and _is_external(m.group(1)):
549+
hits.append((filepath, lineno, stripped, m.group(1)))
550+
for m in re.finditer(r"--from=(\S+)", stripped):
551+
img = m.group(1)
552+
if ("/" in img or ":" in img) and _is_external(img):
553+
hits.append((filepath, lineno, stripped, img))
517554

518555
def _scan_yaml_image_fields():
519-
result = subprocess.run(
520-
[
521-
"find", PROJECT_DIR, "(", "-name", "*.yml", "-o", "-name",
522-
"*.yaml", ")", "-not", "-path", "*/.git/*", "-not", "-path",
523-
"*/.gitlab/*"
524-
],
525-
capture_output=True,
526-
text=True,
527-
)
528-
for filepath in result.stdout.splitlines():
529-
if not filepath.strip():
530-
continue
531-
with open(filepath) as f:
532-
for lineno, line in enumerate(f, 1):
556+
for pattern in ("**/*.yml", "**/*.yaml"):
557+
for filepath in glob.glob(os.path.join(PROJECT_DIR, pattern),
558+
recursive=True):
559+
if "/.git/" in filepath or "/.gitlab/" in filepath:
560+
continue
561+
for lineno, line in _read_lines(filepath):
533562
m = re.match(r"^\s+image:\s+['\"]?(\S+?)['\"]?\s*$", line)
534563
if m and _is_external(m.group(1)):
535564
hits.append(
536565
(filepath, lineno, line.rstrip(), m.group(1)))
537566

538567
def _scan_ci_matrices():
539-
import glob
540568
for filepath in glob.glob(
541569
os.path.join(PROJECT_DIR, ".gitlab", "build-and-test-*.yml")):
542-
with open(filepath) as f:
543-
lines = f.readlines()
544-
for lineno, line in enumerate(lines, 1):
570+
for lineno, line in _read_lines(filepath):
545571
m = re.search(r'BASE_IMAGE:\s*\[(.+)\]', line)
546572
if m:
547573
for img_m in re.finditer(r'"([^"]+)"', m.group(1)):
@@ -610,6 +636,11 @@ def cmd_add(args: argparse.Namespace) -> int:
610636
with open(args.mirror_yaml) as f:
611637
entries = yaml.safe_load(f)
612638

639+
if not isinstance(entries, list):
640+
print(f"ERROR: {args.mirror_yaml} is empty or not a YAML list.",
641+
file=sys.stderr)
642+
return 1
643+
613644
existing = set()
614645
for entry in entries:
615646
if isinstance(entry, str):
@@ -671,8 +702,6 @@ def cmd_relock(args: argparse.Namespace) -> int:
671702
command to re-resolve them. Patterns use fnmatch-style wildcards
672703
(e.g. 'nginx:1.29.*', 'openresty/*').
673704
"""
674-
import fnmatch
675-
676705
lock_path = args.output or LOCK_YAML
677706
if not os.path.exists(lock_path):
678707
print(f"Lock file not found: {lock_path}", file=sys.stderr)

0 commit comments

Comments
 (0)