fix(vm_provision_mixin): stale same-named cloud image cache causes checksum mismatch

_download_cloud_image() cached downloaded images under just the URL's
basename (e.g. ubuntu-26.04-server-cloudimg-amd64.img). Ubuntu's per-build
download URLs change daily under that same stable basename
(.../release-20260713/... vs .../release-20260714/...), so a previous
day's cached file satisfied the "already cached" check and got checksum-
verified against the *new* day's expected hash from NetOrk's daily catalog
sync — failing outright and aborting the whole provisioning job, even
though a plain retry would have re-downloaded and succeeded (the bad file
was already being deleted on mismatch, just never re-fetched).

Found live during a NetOrk deploy: "Checksum mismatch for
https://cloud-images.ubuntu.com/.../release-20260713/
ubuntu-26.04-server-cloudimg-amd64.img: expected 0826c500..., got
3ee4f67f...".

Fix: key the cache path on a hash of the full URL (not just the
basename), and retry the download once after a checksum-mismatch cleanup
before raising.
This commit is contained in:
Christian Manivong
2026-07-14 16:23:13 +02:00
parent 3181ade728
commit 38f0c0a656
2 changed files with 112 additions and 23 deletions
+66 -3
View File
@@ -678,10 +678,73 @@ def test_download_cloud_image_verifies_matching_checksum():
assert path.endswith("debian-12-genericcloud-amd64.qcow2")
def test_download_cloud_image_checksum_mismatch_raises_and_removes_file():
def test_download_cloud_image_cache_key_differs_for_same_basename_different_url():
"""Regression: Ubuntu's per-build URLs change daily under a stable
basename (ubuntu-26.04-server-cloudimg-amd64.img) — a cache key derived
from just the basename let a stale build from a previous day collide
with today's cache lookup, skip re-downloading, and fail checksum
verification against today's expected hash. Gitea issue filed for this."""
mixin = ProxmoxVMProvisionMixin()
mixin._run_node_command = MagicMock(return_value="MISSING")
url_a = (
"https://cloud-images.ubuntu.com/releases/server/releases/resolute/"
"release-20260713/ubuntu-26.04-server-cloudimg-amd64.img"
)
url_b = (
"https://cloud-images.ubuntu.com/releases/server/releases/resolute/"
"release-20260714/ubuntu-26.04-server-cloudimg-amd64.img"
)
path_a = mixin._download_cloud_image(url_a, None, timeout=300)
path_b = mixin._download_cloud_image(url_b, None, timeout=300)
assert path_a != path_b
assert path_a.endswith("ubuntu-26.04-server-cloudimg-amd64.img")
assert path_b.endswith("ubuntu-26.04-server-cloudimg-amd64.img")
def test_download_cloud_image_checksum_mismatch_retries_and_succeeds():
"""The reported bug's actual scenario: a stale same-named cache entry
fails checksum, gets removed, and the retry re-downloads + verifies
successfully instead of failing the whole provisioning job outright."""
mixin = ProxmoxVMProvisionMixin()
mixin._run_node_command = MagicMock(
side_effect=["EXISTS", "wrong-checksum", ""] # existence, checksum, rm
side_effect=[
"EXISTS", # attempt 1: stale file already present
"wrong-checksum", # attempt 1: checksum against stale content
"", # rm -f
"MISSING", # attempt 2: file gone, re-download
"", # wget
"abc123", # attempt 2: checksum against fresh content
]
)
path = mixin._download_cloud_image(
"https://cloud-images.ubuntu.com/releases/server/releases/resolute/"
"release-20260713/ubuntu-26.04-server-cloudimg-amd64.img",
"sha256:abc123",
timeout=300,
)
assert path.endswith("ubuntu-26.04-server-cloudimg-amd64.img")
commands = [c[0][0] for c in mixin._run_node_command.call_args_list]
assert sum(1 for cmd in commands if "wget" in cmd) == 1
assert sum(1 for cmd in commands if cmd.startswith("rm -f")) == 1
def test_download_cloud_image_checksum_mismatch_persists_raises_after_retry():
mixin = ProxmoxVMProvisionMixin()
mixin._run_node_command = MagicMock(
side_effect=[
"EXISTS",
"wrong-checksum",
"", # rm -f (attempt 1)
"MISSING",
"", # wget
"still-wrong",
"", # rm -f (attempt 2)
]
)
with pytest.raises(RuntimeError, match="Checksum mismatch"):
@@ -692,7 +755,7 @@ def test_download_cloud_image_checksum_mismatch_raises_and_removes_file():
)
commands = [c[0][0] for c in mixin._run_node_command.call_args_list]
assert any(cmd.startswith("rm -f") for cmd in commands)
assert sum(1 for cmd in commands if cmd.startswith("rm -f")) == 2
# ---------------------------------------------------------------------------