129 lines
4.7 KiB
Python
129 lines
4.7 KiB
Python
|
|
#!/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()
|