Files
deployment/scripts/docker_inputs_hash.py
T
2026-07-14 12:10:22 +03:00

370 lines
11 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import fnmatch
import hashlib
import json
import os
import shlex
import sys
from dataclasses import dataclass
from pathlib import Path
def _sha256_bytes(data: bytes) -> str:
h = hashlib.sha256()
h.update(data)
return h.hexdigest()
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def _read_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="replace")
@dataclass(frozen=True)
class DockerIgnoreRule:
pattern: str
negated: bool
anchored: bool
directory_only: bool
def _read_dockerignore_rules(context: Path) -> list[DockerIgnoreRule]:
p = context / ".dockerignore"
if not p.exists() or not p.is_file():
return []
rules: list[DockerIgnoreRule] = []
for raw in _read_text(p).splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
negated = line.startswith("!")
if negated:
line = line[1:].lstrip()
anchored = line.startswith("/")
if anchored:
line = line[1:]
directory_only = line.endswith("/")
if directory_only:
line = line[:-1]
if not line:
continue
rules.append(
DockerIgnoreRule(
pattern=line,
negated=negated,
anchored=anchored,
directory_only=directory_only,
)
)
return rules
def _dockerignore_matches(rule: DockerIgnoreRule, rel_posix: str, is_dir: bool) -> bool:
if rule.directory_only and not is_dir:
return False
rel = rel_posix.lstrip("./")
if rule.anchored:
# Anchored to context root.
candidates = [rel]
else:
# Unanchored patterns match anywhere: try both full rel and basename.
base = rel.rsplit("/", 1)[-1]
candidates = [rel, base]
# Dockerignore supports ** globs; fnmatch handles this well enough for our use.
for c in candidates:
if fnmatch.fnmatch(c, rule.pattern):
return True
# Also allow matching directory prefixes for patterns like "dist" against "foo/dist/bar".
if not rule.anchored and "/" in rel:
if fnmatch.fnmatch(rel, f"*/{rule.pattern}") or fnmatch.fnmatch(rel, f"**/{rule.pattern}"):
return True
return False
def _is_ignored_by_dockerignore(rules: list[DockerIgnoreRule], rel_posix: str, is_dir: bool) -> bool:
ignored = False
for r in rules:
if _dockerignore_matches(r, rel_posix=rel_posix, is_dir=is_dir):
ignored = not r.negated
return ignored
def _dockerfile_logical_lines(dockerfile_text: str) -> list[str]:
"""
Join backslash-continued lines and drop full-line comments.
"""
out: list[str] = []
buf: list[str] = []
for raw in dockerfile_text.splitlines():
line = raw.rstrip()
if not buf:
stripped = line.lstrip()
if stripped.startswith("#") or stripped == "":
continue
buf.append(line)
if line.endswith("\\"):
buf[-1] = buf[-1][:-1].rstrip()
continue
joined = " ".join(x.strip() for x in buf if x.strip())
buf = []
if joined:
out.append(joined)
if buf:
joined = " ".join(x.strip() for x in buf if x.strip())
if joined:
out.append(joined)
return out
@dataclass(frozen=True)
class CopyAdd:
sources: tuple[str, ...]
from_stage: bool
def _parse_copy_add_args_shellform(args: list[str]) -> CopyAdd | None:
# flags: --from=, --chown=, --chmod=, --link, --parents, --exclude=... etc.
from_stage = False
rest: list[str] = []
for a in args:
if a.startswith("--from=") or a == "--from":
from_stage = True
continue
if a.startswith("--"):
continue
rest.append(a)
if len(rest) < 2:
return None
# last is dest
srcs = tuple(rest[:-1])
return CopyAdd(sources=srcs, from_stage=from_stage)
def _parse_copy_add_args_jsonform(json_text: str) -> CopyAdd | None:
try:
arr = json.loads(json_text)
except Exception:
return None
if not isinstance(arr, list) or len(arr) < 2:
return None
# last is dest
srcs = tuple(x for x in arr[:-1] if isinstance(x, str))
if not srcs:
return None
return CopyAdd(sources=srcs, from_stage=False)
def _parse_copy_add(line: str) -> CopyAdd | None:
upper = line.lstrip().upper()
if not (upper.startswith("COPY ") or upper.startswith("ADD ")):
return None
# Keep original casing for paths.
keyword, rest = line.split(None, 1)
rest = rest.strip()
# JSON form starts with '['
if rest.startswith("["):
parsed = _parse_copy_add_args_jsonform(rest)
if parsed:
return parsed
return None
# shell form
try:
parts = shlex.split(rest, posix=True)
except Exception:
return None
return _parse_copy_add_args_shellform(parts)
def _looks_like_remote(src: str) -> bool:
s = src.lower()
return s.startswith("http://") or s.startswith("https://")
def _is_glob(p: str) -> bool:
return any(ch in p for ch in ["*", "?", "["])
def _iter_files_under(path: Path) -> list[Path]:
if not path.exists():
return []
if path.is_file():
return [path]
files: list[Path] = []
for root, _, filenames in os.walk(path):
for name in filenames:
files.append(Path(root) / name)
return files
def _collect_sources(context: Path, dockerfile_path: Path) -> list[Path]:
text = _read_text(dockerfile_path)
logical = _dockerfile_logical_lines(text)
dockerignore_rules = _read_dockerignore_rules(context)
paths: list[Path] = []
for ln in logical:
parsed = _parse_copy_add(ln)
if not parsed:
continue
if parsed.from_stage:
continue
for src in parsed.sources:
if _looks_like_remote(src):
continue
if src.startswith("/"):
# Absolute COPY sources aren't valid for local context; ignore to avoid surprises.
continue
# Docker allows ".", "./foo", etc.
src_norm = src.lstrip("./")
if src_norm == "":
src_norm = "."
if _is_glob(src_norm):
# Expand within context
for root, _, filenames in os.walk(context):
root_p = Path(root)
rel_root = root_p.relative_to(context).as_posix()
for fn in filenames:
rel = f"{rel_root}/{fn}" if rel_root != "." else fn
if _is_ignored_by_dockerignore(dockerignore_rules, rel_posix=rel, is_dir=False):
continue
if fnmatch.fnmatch(rel, src_norm) or fnmatch.fnmatch(fn, src_norm):
paths.append(context / rel)
continue
p = (context / src_norm).resolve()
# Ensure stays within context
try:
p.relative_to(context.resolve())
except Exception:
continue
for fp in _iter_files_under(p):
try:
rel = fp.resolve().relative_to(context.resolve()).as_posix()
except Exception:
continue
if _is_ignored_by_dockerignore(dockerignore_rules, rel_posix=rel, is_dir=fp.is_dir()):
continue
paths.append(fp)
# Always include the Dockerfile itself (and preserve stable ordering via sort later)
paths.append(dockerfile_path.resolve())
return paths
def compute_inputs_hash(context: Path, dockerfile_path: Path) -> str:
files = _collect_sources(context=context, dockerfile_path=dockerfile_path)
# Deduplicate by resolved path
uniq: dict[str, Path] = {}
for p in files:
uniq[str(p)] = p
# Stable sort by path relative to context when possible, else absolute
ctx_resolved = context.resolve()
def sort_key(p: Path) -> str:
try:
return p.resolve().relative_to(ctx_resolved).as_posix()
except Exception:
return p.resolve().as_posix()
sorted_files = sorted(uniq.values(), key=sort_key)
h = hashlib.sha256()
for p in sorted_files:
rp: str
try:
rp = p.resolve().relative_to(ctx_resolved).as_posix()
except Exception:
rp = p.resolve().as_posix()
h.update(rp.encode("utf-8", errors="strict"))
h.update(b"\0")
if p.is_file():
h.update(_sha256_file(p).encode("ascii"))
else:
h.update(b"NONFILE")
h.update(b"\n")
return h.hexdigest()
def compute_inputs_debug(context: Path, dockerfile_path: Path) -> tuple[str, list[tuple[str, str]]]:
"""
Returns (inputs_hash, [(rel_path, sha256_of_file_contents), ...]) with dockerignore applied.
"""
files = _collect_sources(context=context, dockerfile_path=dockerfile_path)
uniq: dict[str, Path] = {}
for p in files:
uniq[str(p.resolve())] = p.resolve()
ctx_resolved = context.resolve()
def rel_or_abs(p: Path) -> str:
try:
return p.resolve().relative_to(ctx_resolved).as_posix()
except Exception:
return p.resolve().as_posix()
sorted_files = sorted(uniq.values(), key=lambda p: rel_or_abs(p))
items: list[tuple[str, str]] = []
for p in sorted_files:
rp = rel_or_abs(p)
if p.is_file():
items.append((rp, _sha256_file(p)))
else:
items.append((rp, "NONFILE"))
return compute_inputs_hash(context=context, dockerfile_path=dockerfile_path), items
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--context", required=True, help="Build context directory")
ap.add_argument("--dockerfile", required=True, help="Dockerfile path")
ap.add_argument(
"--debug-list",
action="store_true",
help="Print the included file list (relpath|sha256) to stderr",
)
args = ap.parse_args()
context = Path(args.context).resolve()
dockerfile = Path(args.dockerfile).resolve()
if not context.exists() or not context.is_dir():
print(f"Context not found or not a directory: {context}", file=sys.stderr)
return 2
if not dockerfile.exists() or not dockerfile.is_file():
print(f"Dockerfile not found: {dockerfile}", file=sys.stderr)
return 2
if args.debug_list:
h, items = compute_inputs_debug(context=context, dockerfile_path=dockerfile)
for rp, sh in items:
print(f"{rp}|{sh}", file=sys.stderr)
print(h)
else:
print(compute_inputs_hash(context=context, dockerfile_path=dockerfile))
return 0
if __name__ == "__main__":
raise SystemExit(main())