diff --git a/src/wudup/updater_lifecycle_digest.py b/src/wudup/updater_lifecycle_digest.py index 86477c46..81d8eac9 100644 --- a/src/wudup/updater_lifecycle_digest.py +++ b/src/wudup/updater_lifecycle_digest.py @@ -67,47 +67,77 @@ def _verify_expected_digests( for match in matches if (expected_digest := _expected_digest_requirement(match)) } - for line_no, target, expected_image, allow_repo, expected in sorted(requirements): - matched = False - digest_result: DigestCheckResult | None = None - stale_result: DigestCheckResult | None = None - for image in images: - if not image_matches_resolved_target(image, expected_image, allow_repo): - continue - matched = True - digest_result = self.digest_verifier.verify(image, expected) - if digest_result.reason == "stale-digest": - stale_result = digest_result - if digest_result.ok: - break - if digest_result is not None and digest_result.status == "untrusted": - self.log.warn( - f"[{stack.name}] Digest verification was inconclusive for line {line_no} ({target}): wanted {expected}" - ) - self._log_digest_untrusted(stack.name, digest_result) - continue - if digest_result is None or not digest_result.ok: + for requirement in sorted(requirements): + if not self._verify_expected_digest_requirement( + stack, + requirement, + images, + ): ok = False - self.log.error( - f"[{stack.name}] Expected digest not reached for line {line_no} ({target}): wanted {expected}" - ) - if digest_result is not None: - self._log_digest_mismatch(stack.name, digest_result) - if stale_result is not None: - self._mark_stale_pending_digest( - stack, - line_no, - target, - expected, - stale_result, - ) - if not matched: - self.log.plain( - "ERROR", - f"[{stack.name}] No compose image matched line {line_no} while checking expected digest", - ) return ok + def _verify_expected_digest_requirement( + self, + stack: ComposeStack, + requirement: tuple[int, str, str, bool, str], + images: Sequence[str], + ) -> bool: + line_no, target, expected_image, allow_repo, expected = requirement + digest_result, stale_result, matched = self._verify_expected_digest_images( + expected_image, + allow_repo, + expected, + images, + ) + if digest_result is not None and digest_result.status == "untrusted": + self.log.warn( + f"[{stack.name}] Digest verification was inconclusive for line {line_no} ({target}): wanted {expected}" + ) + self._log_digest_untrusted(stack.name, digest_result) + return True + if digest_result is not None and digest_result.ok: + return True + self.log.error( + f"[{stack.name}] Expected digest not reached for line {line_no} ({target}): wanted {expected}" + ) + if digest_result is not None: + self._log_digest_mismatch(stack.name, digest_result) + if stale_result is not None: + self._mark_stale_pending_digest( + stack, + line_no, + target, + expected, + stale_result, + ) + if not matched: + self.log.plain( + "ERROR", + f"[{stack.name}] No compose image matched line {line_no} while checking expected digest", + ) + return False + + def _verify_expected_digest_images( + self, + expected_image: str, + allow_repo: bool, + expected: str, + images: Sequence[str], + ) -> tuple[DigestCheckResult | None, DigestCheckResult | None, bool]: + matched = False + digest_result: DigestCheckResult | None = None + stale_result: DigestCheckResult | None = None + for image in images: + if not image_matches_resolved_target(image, expected_image, allow_repo): + continue + matched = True + digest_result = self.digest_verifier.verify(image, expected) + if digest_result.reason == "stale-digest": + stale_result = digest_result + if digest_result.ok: + break + return digest_result, stale_result, matched + def _mark_stale_pending_digest( self, stack: ComposeStack, @@ -144,65 +174,88 @@ def _verify_digest_pin_updates( ) -> bool: ok = True for update in updates: - current = self._verify_digest_pin_update_target(update) - if not current.ok: + if not self._verify_digest_pin_update(stack, update, images): ok = False - if current.reason == "stale-digest": - current_digest = normalize_digest(current.digest) - suffix = f", current {current_digest}" if current_digest else "" - self.log.plain( - "ERROR", - f"[{stack.name}] Digest-pin target moved for " - f"{update.resolved_image}: planned {update.planned_digest}" - f"{suffix}", - ) - else: - self.log.error( - f"[{stack.name}] Could not re-resolve digest-pin target " - f"{update.resolved_image}: {current.reason}" - ) - if current.error: - self.log.plain( - "ERROR", - f"[{stack.name}] Digest resolution error: {updater_logging.sanitize_stream(current.error)}", - ) - continue - matched = False - digest_result: DigestCheckResult | None = None - for image in images: - if not image_matches_resolved_target( - image, - update.resolved_image, - False, - ): - continue - matched = True - digest_result = self.digest_verifier.verify( - image, - update.planned_digest, - ) - if digest_result.ok: - break - if digest_result is not None and digest_result.ok: - self.log.info( - f"[{stack.name}] Verified digest-pin target: " - f"{update.resolved_image} -> {update.planned_digest}" - ) + return ok + + def _verify_digest_pin_update( + self, + stack: ComposeStack, + update: DigestPinUpdate, + images: Sequence[str], + ) -> bool: + current = self._verify_digest_pin_update_target(update) + if not current.ok: + self._log_digest_pin_resolution_failure(stack, update, current) + return False + digest_result, matched = self._verify_digest_pin_images(update, images) + if digest_result is not None and digest_result.ok: + self.log.info( + f"[{stack.name}] Verified digest-pin target: " + f"{update.resolved_image} -> {update.planned_digest}" + ) + return True + self.log.error( + f"[{stack.name}] Digest-pin target did not verify for " + f"{update.resolved_image}: wanted {update.planned_digest}" + ) + if digest_result is not None: + self._log_digest_mismatch(stack.name, digest_result) + if not matched: + self.log.plain( + "ERROR", + f"[{stack.name}] No compose image matched digest-pin target " + f"{update.resolved_image}", + ) + return False + + def _log_digest_pin_resolution_failure( + self, + stack: ComposeStack, + update: DigestPinUpdate, + current: DigestResolveResult, + ) -> None: + if current.reason == "stale-digest": + current_digest = normalize_digest(current.digest) + suffix = f", current {current_digest}" if current_digest else "" + self.log.plain( + "ERROR", + f"[{stack.name}] Digest-pin target moved for " + f"{update.resolved_image}: planned {update.planned_digest}{suffix}", + ) + return + self.log.error( + f"[{stack.name}] Could not re-resolve digest-pin target " + f"{update.resolved_image}: {current.reason}" + ) + if current.error: + self.log.plain( + "ERROR", + f"[{stack.name}] Digest resolution error: {updater_logging.sanitize_stream(current.error)}", + ) + + def _verify_digest_pin_images( + self, + update: DigestPinUpdate, + images: Sequence[str], + ) -> tuple[DigestCheckResult | None, bool]: + matched = False + digest_result: DigestCheckResult | None = None + for image in images: + if not image_matches_resolved_target( + image, + update.resolved_image, + False, + ): continue - ok = False - self.log.error( - f"[{stack.name}] Digest-pin target did not verify for " - f"{update.resolved_image}: wanted {update.planned_digest}" + matched = True + digest_result = self.digest_verifier.verify( + image, + update.planned_digest, ) - if digest_result is not None: - self._log_digest_mismatch(stack.name, digest_result) - if not matched: - self.log.plain( - "ERROR", - f"[{stack.name}] No compose image matched digest-pin target " - f"{update.resolved_image}", - ) - return ok + if digest_result.ok: + break + return digest_result, matched def _log_digest_untrusted( self, diff --git a/tests/test_python_update_from_wud_core.py b/tests/test_python_update_from_wud_core.py index 26255dde..ec470397 100644 --- a/tests/test_python_update_from_wud_core.py +++ b/tests/test_python_update_from_wud_core.py @@ -2,11 +2,13 @@ import json import unittest +from unittest import mock from wudup.compose import ( ComposeStack, ServiceImage, ) +from wudup.digest_verifier import DigestCheckResult from wudup.updater_lifecycle_health import _updated_images from wudup.updater_models import ( ImageState, @@ -90,6 +92,54 @@ def test_expected_digest_failure_reason_requires_all_matches_stale(self) -> None runner.lifecycle._expected_digest_failure_reason(stack, matches), STALE_PENDING_DIGEST_REASON, ) + def test_expected_digest_verification_checks_all_requirements(self) -> None: + stack_dir = self.make_stack( + "app", + [ + ("first", "repo/first:latest", "cid-first"), + ("second", "repo/second:latest", "cid-second"), + ], + ) + stack = ComposeStack( + index=1, + directory=stack_dir, + file="docker-compose.yml", + name="app", + images=("repo/first:latest", "repo/second:latest"), + service_images=( + ServiceImage("first", "repo/first:latest"), + ServiceImage("second", "repo/second:latest"), + ), + ) + targets = parse_wud_text( + "repo/first:latest@sha256:first\n" + "repo/second:latest@sha256:second\n" + ).targets + matches = tuple( + Match( + stack=stack, + target=target, + resolved=stack.images[index], + compose_image=stack.images[index], + service=stack.service_images[index].service, + ) + for index, target in enumerate(targets) + ) + digest_verifier = mock.Mock() + digest_verifier.verify.side_effect = ( + DigestCheckResult(False, "mismatch", "digest-mismatch"), + DigestCheckResult(True, "verified", "digest-match"), + ) + runner = self.make_runner(digest_verifier=digest_verifier) + + verified = runner.lifecycle._verify_expected_digests( + stack, + matches, + stack.images, + ) + + self.assertFalse(verified) + self.assertEqual(digest_verifier.verify.call_count, 2) def test_wrapper_default_dry_run_plans_without_mutation(self) -> None: self.wud_file.write_text("repo/app:latest\n", encoding="utf-8") self.make_stack("app", [("app", "repo/app:latest", "cid-app")]) diff --git a/tests/test_python_update_from_wud_digest_pins.py b/tests/test_python_update_from_wud_digest_pins.py index a537dcfd..d489d382 100644 --- a/tests/test_python_update_from_wud_digest_pins.py +++ b/tests/test_python_update_from_wud_digest_pins.py @@ -12,6 +12,8 @@ ) from wudup.config import load_config from wudup.digest_verifier import ( + DigestCheckResult, + DigestResolveResult, DigestVerifier, DockerManifestResolver, ) @@ -175,6 +177,141 @@ def test_digest_pin_verification_matches_canonical_compose_image(self) -> None: stack.images, ) ) + def test_digest_pin_verification_checks_all_updates_after_failure(self) -> None: + stack = ComposeStack( + index=1, + directory=self.root, + file="docker-compose.yml", + name="app", + images=("docker.io/repo/second:2.0",), + service_images=( + ServiceImage("second", "docker.io/repo/second:2.0"), + ), + ) + updates = ( + digest_pin_update_from_values( + old_image="repo/first:1.0", + resolved_tag="2.0", + planned_digest="sha256:first", + services=("first",), + ), + digest_pin_update_from_values( + old_image="repo/second:1.0", + resolved_tag="2.0", + planned_digest="sha256:second", + services=("second",), + ), + ) + digest_verifier = mock.Mock() + digest_verifier.verify.return_value = DigestCheckResult( + True, + "verified", + "digest-match", + ) + runner = self.make_runner(digest_verifier=digest_verifier) + + with mock.patch.object( + runner.lifecycle, + "_verify_digest_pin_update_target", + side_effect=( + DigestResolveResult(False, "mismatch", "stale-digest"), + DigestResolveResult( + True, + "verified", + "digest-match", + digest="sha256:second", + ), + ), + ) as resolve: + verified = runner._verify_digest_pin_updates( + stack, + updates, + stack.images, + ) + + self.assertFalse(verified) + self.assertEqual(resolve.call_count, 2) + digest_verifier.verify.assert_called_once_with( + "docker.io/repo/second:2.0", + "sha256:second", + ) + def test_digest_pin_verification_checks_all_updates_after_digest_failure( + self, + ) -> None: + stack = ComposeStack( + index=1, + directory=self.root, + file="docker-compose.yml", + name="app", + images=( + "docker.io/repo/first:2.0", + "docker.io/repo/second:2.0", + ), + service_images=( + ServiceImage("first", "docker.io/repo/first:2.0"), + ServiceImage("second", "docker.io/repo/second:2.0"), + ), + ) + updates = ( + digest_pin_update_from_values( + old_image="repo/first:1.0", + resolved_tag="2.0", + planned_digest="sha256:first", + services=("first",), + ), + digest_pin_update_from_values( + old_image="repo/second:1.0", + resolved_tag="2.0", + planned_digest="sha256:second", + services=("second",), + ), + ) + digest_verifier = mock.Mock() + digest_verifier.verify.side_effect = ( + DigestCheckResult(False, "mismatch", "digest-mismatch"), + DigestCheckResult(True, "verified", "digest-match"), + ) + runner = self.make_runner(digest_verifier=digest_verifier) + + with mock.patch.object( + runner.lifecycle, + "_verify_digest_pin_update_target", + side_effect=( + DigestResolveResult( + True, + "verified", + "digest-match", + digest="sha256:first", + ), + DigestResolveResult( + True, + "verified", + "digest-match", + digest="sha256:second", + ), + ), + ) as resolve: + verified = runner._verify_digest_pin_updates( + stack, + updates, + stack.images, + ) + + self.assertFalse(verified) + self.assertEqual( + resolve.call_args_list, + [ + mock.call(updates[0]), + mock.call(updates[1]), + ], + ) + self.assertEqual( + digest_verifier.verify.call_args_list, + [ + mock.call("docker.io/repo/first:2.0", "sha256:first"), + mock.call("docker.io/repo/second:2.0", "sha256:second"), + ], + ) def test_digest_pin_plan_includes_digest_actions_and_hashes_digest(self) -> None: self.wud_file.write_text("repo/app:1.0 tag=2.0\n", encoding="utf-8") self.make_stack("app", [("app", "repo/app:1.0", "cid-app")])