1111"""
1212
1313import argparse
14+ import fnmatch
15+ import glob
16+ import hashlib
1417import json
1518import os
1619import re
@@ -53,10 +56,11 @@ def _find_digest_tool() -> str:
5356
5457def _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
143147def 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