erplibre/test/test_qemu_download_resume.py

129 lines
4.7 KiB
Python
Raw Normal View History

[FIX] qemu : reprendre un téléchargement d'image coupé Une image de VM pèse un demi-gigaoctet : une coupure y est bien plus probable que sur une page. Le contrôle de complétude existait — sans lui un .part tronqué passait pour une image, donnant un qcow2 valide mais VIDE — mais il jetait les octets reçus et renvoyait l'utilisateur à un « curl -C - » tapé à la main. Le transfert se reprend désormais sur le même miroir, trois essais, par un « Range » qui demande la suite ; on ne change de miroir qu'ensuite. Un serveur qui ignore le Range rend le fichier entier, et l'on repart alors de zéro plutôt que de doubler les octets déjà là. Vérifié : 5 tests contre un serveur qui coupe au tiers, image reconstituée à l'octet près, et le .part tronqué part quand les reprises s'épuisent. --- EN --- A VM image weighs half a gigabyte: an interruption is far likelier there than on a page. The completeness check existed — without it a truncated .part passed for an image, giving a valid but EMPTY qcow2 — but it threw away the bytes received and sent the user back to a hand-typed "curl -C -". The transfer now resumes on the same mirror, three attempts, through a "Range" asking for the rest; only then does it change mirror. A server ignoring the Range returns the whole file, and we restart from zero rather than doubling the bytes already there. Checked: 5 tests against a server cutting at one third, image reassembled byte for byte, and the truncated .part goes when the retries run out. Assisted-by: Claude Opus 5
2026-09-24 13:33:36 -04:00
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Un transfert coupé se reprend-il, et le fichier est-il intact ?
Une image de VM pèse un demi-gigaoctet : une coupure y est bien plus probable
que sur une page, et repartir de zéro à chaque fois peut ne jamais aboutir. Le
contrôle de complétude existait déjà — sans lui un .part tronqué passait pour
une image, donnant un qcow2 valide mais VIDE, et une VM qui ne démarre pas —
mais il jetait les octets reçus.
Deux pièges que ces tests gardent :
- les octets déjà écrits sont CONSERVÉS entre deux essais, et la suite est
demandée par « Range ». Les jeter rendrait chaque reprise aussi longue que
le premier essai ;
- un serveur qui IGNORE « Range » rend 200 et le fichier entier : il faut
alors repartir de zéro, sans quoi les octets déjà là seraient doublés et
l'image illisible.
"""
import http.server
import sys
import tempfile
import threading
import unittest
from pathlib import Path
RACINE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(RACINE))
from script.qemu import deploy_qemu as d # noqa: E402
CONTENU = b"".join(bytes([i % 251]) for i in range(60_000))
class Serveur:
"""Un serveur qui coupe ses `coupures` premiers transferts au tiers."""
def __init__(self, coupures, honore_range=True):
self.restantes = coupures
self.honore_range = honore_range
self.demandes = []
contexte = self
class Poignee(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def do_GET(self):
rng = self.headers.get("Range")
contexte.demandes.append(rng)
debut = 0
if rng and contexte.honore_range:
debut = int(rng.split("=")[1].split("-")[0])
self.send_response(206)
self.send_header(
"Content-Range",
f"bytes {debut}-{len(CONTENU) - 1}/{len(CONTENU)}",
)
else:
self.send_response(200)
reste = CONTENU[debut:]
self.send_header("Content-Length", str(len(reste)))
self.end_headers()
if contexte.restantes > 0:
contexte.restantes -= 1
self.wfile.write(reste[: len(reste) // 3])
else:
self.wfile.write(reste)
self.httpd = http.server.HTTPServer(("127.0.0.1", 0), Poignee)
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
@property
def url(self):
return f"http://127.0.0.1:{self.httpd.server_address[1]}/image.qcow2"
def arrete(self):
self.httpd.shutdown()
class TestLaReprise(unittest.TestCase):
def telecharge(self, serveur):
self.addCleanup(serveur.arrete)
with tempfile.TemporaryDirectory() as coin:
dest = Path(coin) / "image.qcow2"
d.download_image([serveur.url], dest, dry_run=False, timeout=10)
return dest.read_bytes()
def test_une_coupure_est_reprise(self):
serveur = Serveur(coupures=1)
self.assertEqual(CONTENU, self.telecharge(serveur))
def test_deux_coupures_sont_reprises(self):
serveur = Serveur(coupures=2)
self.assertEqual(CONTENU, self.telecharge(serveur))
def test_la_suite_est_demandee_par_range(self):
"""Sans Range, la reprise retéléchargerait tout."""
serveur = Serveur(coupures=1)
self.telecharge(serveur)
self.assertIsNone(serveur.demandes[0])
self.assertTrue(serveur.demandes[1].startswith("bytes="))
def test_un_serveur_qui_ignore_range_repart_de_zero(self):
"""Sinon les octets déjà là seraient doublés, et l'image illisible."""
serveur = Serveur(coupures=1, honore_range=False)
self.assertEqual(CONTENU, self.telecharge(serveur))
def test_au_dela_des_reprises_le_miroir_suivant(self):
"""Trois essais, puis on change de miroir plutôt que d'insister."""
serveur = Serveur(coupures=d.REPRISES_MAX)
self.addCleanup(serveur.arrete)
with tempfile.TemporaryDirectory() as coin:
dest = Path(coin) / "image.qcow2"
with self.assertRaises(SystemExit):
d.download_image([serveur.url], dest, False, timeout=10)
self.assertFalse(
dest.with_suffix(dest.suffix + ".part").exists(),
"le .part tronqué doit partir, il empoisonnerait le cache",
)
if __name__ == "__main__":
unittest.main()