mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-09 11:46:49 +00:00
A job stored its error and its result's warning as English text, much of it built with the values in it (volume paths, a disk, libvirt's error), so no catalog could translate it, and the job pages and panel showed English whatever the language. A job now stores them as messages: our English text, marked for translation, and its values; libvirt's own text is a message without values and is shown as it is. The jobs API and pages put them in the reader's language. OperationError keeps its text and values apart for the same reason, so a refusal raised while a job is submitted is shown translated too. Messages one after another still read as sentences. Errors stored before are kept as one message each, shown as they were.
689 lines
31 KiB
Python
689 lines
31 KiB
Python
"""An ISO upload is a job a request runs: each chunk request writes one chunk
|
|
and commits it on the job (spec: the chunk protocol)."""
|
|
|
|
import errno
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
from datetime import timedelta
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.core.exceptions import PermissionDenied
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
from django.contrib.sessions.middleware import SessionMiddleware
|
|
from django.test import RequestFactory, TestCase
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
from libvirt import VIR_ERR_NO_STORAGE_VOL, VIR_ERR_SYSTEM_ERROR, libvirtError
|
|
|
|
from computes.models import Compute
|
|
from operations.utils import words
|
|
from operations import claims, jobs
|
|
from operations.models import Claim, Operation
|
|
from operations.worker import housekeeping
|
|
from storages.upload import IDLE, _atomic_finalize_local, _ssh_pool, _UploadTarget, _VolumeTarget, write_chunk
|
|
from vrtManager.connection import CONN_SOCKET, CONN_SSH, CONN_TCP, CONN_TLS
|
|
|
|
User = get_user_model()
|
|
KEY = "upload-key-0001"
|
|
|
|
|
|
class UploadTestCase(TestCase):
|
|
"""A pool on conn; the kind and the ending path open the same conn."""
|
|
|
|
def setUp(self):
|
|
self.compute = Compute.objects.create(name="up-c", hostname="192.0.2.30", login="root", password="", type=1)
|
|
self.admin = User.objects.create_superuser("up_admin", "up_admin@example.com", "pw")
|
|
self.conn = self.make_conn()
|
|
self.opened = {}
|
|
for target in ("storages.jobs.wvmStorage", "storages.upload.wvmStorage"):
|
|
patcher = patch(target, return_value=self.conn)
|
|
self.opened[target] = patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
def cancel_during_write(self, target_class):
|
|
"""The panel's cancel while this request writes: it finds the upload held."""
|
|
write = target_class.write
|
|
|
|
def cancelled(target, *args):
|
|
Operation.objects.filter(kind="volume.upload").update(state=Operation.CANCELLING, cancel_requested=True)
|
|
return write(target, *args)
|
|
|
|
return patch.object(target_class, "write", cancelled)
|
|
|
|
def request(self, user=None):
|
|
"""A chunk request of user (the admin), with the session remember_job writes to."""
|
|
request = RequestFactory().post("/")
|
|
request.user = user or self.admin
|
|
SessionMiddleware(lambda r: None).process_request(request)
|
|
return request
|
|
|
|
def send(self, index, data, total=3, size=9, name="disk.iso", key=KEY, user=None):
|
|
return write_chunk(self.request(user), self.conn, self.compute, "default", key,
|
|
{"name": name, "size": size, "chunks": total}, index, SimpleUploadedFile("chunk", data))
|
|
|
|
def job(self):
|
|
return Operation.objects.get(kind="volume.upload")
|
|
|
|
|
|
class LocalPool:
|
|
def make_conn(self):
|
|
self.pool = tempfile.mkdtemp()
|
|
self.addCleanup(shutil.rmtree, self.pool, ignore_errors=True)
|
|
conn = MagicMock(conn=CONN_SOCKET)
|
|
conn.get_target_path.return_value = self.pool
|
|
conn.get_volumes.side_effect = lambda: sorted(os.listdir(self.pool))
|
|
return conn
|
|
|
|
def path(self, name="disk.iso"):
|
|
return os.path.join(self.pool, name)
|
|
|
|
def part(self, op):
|
|
return self.path(f"disk.iso.wvc_upload_{op.pk.hex}.part")
|
|
|
|
def read(self, path):
|
|
with open(path, "rb") as stream:
|
|
return stream.read()
|
|
|
|
|
|
class LocalUploadTests(LocalPool, UploadTestCase):
|
|
def test_three_chunks_are_one_job(self):
|
|
op = self.send(0, b"aaa")
|
|
op.refresh_from_db()
|
|
self.assertEqual((op.state, op.worker, op.progress_done, op.progress_total), (Operation.RUNNING, Operation.REQUEST, 3, 9))
|
|
self.assertEqual(op.intent, {"chunk": 1, "created": True})
|
|
self.assertEqual(set(Claim.objects.values_list("resource", "mode")), {
|
|
(f"compute:{self.compute.pk}", "shared"), (f"pool:{self.compute.pk}:default", "shared"),
|
|
(f"volume:{self.compute.pk}:default/disk.iso", "exclusive")})
|
|
self.assertEqual(self.read(self.part(op)), b"aaa")
|
|
self.send(1, b"bbb")
|
|
op.refresh_from_db()
|
|
self.assertEqual(op.progress_done, 6)
|
|
self.send(2, b"ccc")
|
|
op.refresh_from_db()
|
|
self.assertEqual(op.state, Operation.SUCCEEDED)
|
|
self.assertEqual(self.read(self.path()), b"aaabbbccc")
|
|
self.assertFalse(os.path.exists(self.part(op)))
|
|
self.assertFalse(Claim.objects.exists())
|
|
self.assertEqual(Operation.objects.count(), 1)
|
|
|
|
def test_a_duplicate_chunk_is_acknowledged_without_a_write(self):
|
|
op = self.send(0, b"aaa")
|
|
self.assertEqual(self.send(0, b"zzz").pk, op.pk)
|
|
self.send(1, b"bbb")
|
|
self.send(1, b"yyy")
|
|
self.send(2, b"ccc")
|
|
# the final answer was lost: the retry is answered success
|
|
self.assertEqual(self.send(2, b"ccc").state, Operation.SUCCEEDED)
|
|
self.assertEqual(self.read(self.path()), b"aaabbbccc")
|
|
|
|
def test_a_duplicate_last_chunk_finishes_a_committed_upload(self):
|
|
op = self.send(0, b"aaa", total=1, size=3)
|
|
Operation.objects.filter(pk=op.pk).update(state=Operation.RUNNING, finished_at=None)
|
|
os.rename(self.path(), self.part(op)) # the request died before the rename
|
|
self.assertEqual(self.send(0, b"aaa", total=1, size=3).state, Operation.SUCCEEDED)
|
|
self.assertEqual(self.read(self.path()), b"aaa")
|
|
|
|
def test_a_link_an_interrupted_rename_made_is_completed(self):
|
|
op = self.committed_but_not_renamed()
|
|
os.link(self.part(op), self.path()) # the request died after the link, before the unlink
|
|
self.assertEqual(self.send(1, b"bbb", total=2, size=6).state, Operation.SUCCEEDED)
|
|
self.assertEqual(os.listdir(self.pool), ["disk.iso"])
|
|
self.assertEqual(self.read(self.path()), b"aaabbb")
|
|
|
|
def test_an_image_gone_before_the_completion_fails_the_job(self):
|
|
op = self.committed_but_not_renamed()
|
|
os.unlink(self.part(op))
|
|
with self.assertRaisesRegex(ValueError, "The uploaded image is missing"):
|
|
self.send(1, b"bbb", total=2, size=6)
|
|
op.refresh_from_db()
|
|
self.assertEqual((op.state, words(op.error)), (Operation.FAILED, "The uploaded image is missing"))
|
|
self.assertFalse(Claim.objects.exists())
|
|
|
|
def test_the_first_write_syncs_the_pool_directory_before_the_commit(self):
|
|
synced = []
|
|
with patch("storages.upload._fsync_dir", side_effect=lambda d: synced.append((d, self.job().progress_done))):
|
|
self.send(0, b"aaa")
|
|
self.send(1, b"bbb")
|
|
self.assertEqual(synced, [(self.pool, None)])
|
|
|
|
def test_sequence_and_session_are_checked(self):
|
|
with self.assertRaisesRegex(ValueError, "not found"):
|
|
self.send(1, b"bbb")
|
|
self.send(0, b"aaa")
|
|
with self.assertRaisesRegex(ValueError, "sequence"):
|
|
self.send(2, b"ccc")
|
|
for changed in ({"size": 10}, {"total": 4}, {"name": "other.iso"}):
|
|
with self.assertRaisesRegex(ValueError, "changed"):
|
|
self.send(1, b"bbb", **changed)
|
|
with self.assertRaises(jobs.SubmissionConflict):
|
|
self.send(0, b"aaa", size=10)
|
|
with self.assertRaisesRegex(ValueError, "declared file size"):
|
|
self.send(1, b"bbbbbbb")
|
|
self.assertEqual(self.job().progress_done, 3)
|
|
|
|
def test_a_writer_that_died_mid_chunk_is_cut_back(self):
|
|
op = self.send(0, b"aaa")
|
|
with open(self.part(op), "ab") as stream:
|
|
stream.write(b"UNCOMMITTED")
|
|
self.send(1, b"bbb")
|
|
self.assertEqual(self.read(self.part(op)), b"aaabbb")
|
|
|
|
def test_an_interrupted_first_write_is_retried_from_the_start(self):
|
|
chunk = SimpleUploadedFile("disk.iso", b"aaa")
|
|
|
|
def interrupted():
|
|
yield b"a"
|
|
raise OSError("Interrupted first write")
|
|
|
|
chunk.chunks = interrupted
|
|
with self.assertRaises(OSError):
|
|
write_chunk(self.request(), self.conn, self.compute, "default", KEY,
|
|
{"name": "disk.iso", "size": 9, "chunks": 3}, 0, chunk)
|
|
op = self.job()
|
|
self.assertEqual((op.intent, op.progress_done), ({"chunk": 0, "created": True}, None))
|
|
self.assertEqual(self.read(self.part(op)), b"a")
|
|
self.send(0, b"aaa")
|
|
self.assertEqual(self.read(self.part(op)), b"aaa")
|
|
|
|
def test_a_failure_before_the_part_file_is_retried(self):
|
|
with patch("storages.upload._UploadTarget.write", side_effect=OSError("Cannot open part")):
|
|
with self.assertRaises(OSError):
|
|
self.send(0, b"aaa", total=1, size=3)
|
|
self.send(0, b"aaa", total=1, size=3)
|
|
self.assertEqual(self.read(self.path()), b"aaa")
|
|
|
|
def test_a_name_taken_when_renameat2_finalizes_is_a_taken_name(self):
|
|
part, final = self.path("disk.iso.part"), self.path()
|
|
with open(part, "wb") as stream:
|
|
stream.write(b"aaa")
|
|
libc = MagicMock()
|
|
libc.renameat2.return_value = -1
|
|
with patch("os.link", side_effect=OSError(errno.EPERM, "no hard links")), \
|
|
patch("ctypes.CDLL", return_value=libc), patch("ctypes.get_errno", return_value=errno.EEXIST):
|
|
with self.assertRaises(FileExistsError):
|
|
_atomic_finalize_local(part, final)
|
|
self.assertEqual(self.read(part), b"aaa")
|
|
|
|
def test_a_taken_name_is_refused_without_a_job(self):
|
|
with open(self.path(), "wb") as stream:
|
|
stream.write(b"mine")
|
|
with self.assertRaises(FileExistsError):
|
|
self.send(0, b"aaa")
|
|
self.assertFalse(Operation.objects.exists())
|
|
self.assertFalse(Claim.objects.exists())
|
|
|
|
def test_a_destination_that_appears_before_the_rename_fails_the_job(self):
|
|
op = self.send(0, b"aaa")
|
|
self.send(1, b"bbb")
|
|
with open(self.path(), "wb") as stream:
|
|
stream.write(b"mine")
|
|
with self.assertRaises(FileExistsError):
|
|
self.send(2, b"ccc")
|
|
op.refresh_from_db()
|
|
self.assertEqual(op.state, Operation.FAILED)
|
|
self.assertFalse(os.path.exists(self.part(op)))
|
|
self.assertEqual(self.read(self.path()), b"mine")
|
|
self.assertFalse(Claim.objects.exists())
|
|
|
|
def test_cancel_deletes_the_part_file(self):
|
|
op = self.send(0, b"aaa")
|
|
op = jobs.cancel(op.pk, self.admin)
|
|
self.assertEqual(op.state, Operation.CANCELLED)
|
|
self.assertFalse(os.path.exists(self.part(op)))
|
|
self.assertFalse(Claim.objects.exists())
|
|
with self.assertRaises(ValueError):
|
|
self.send(1, b"bbb")
|
|
jobs.get_kind("volume.upload").cancel(op) # already ended: nothing to do
|
|
|
|
def test_a_cancel_during_a_chunk_is_ended_by_the_next_chunk(self):
|
|
op = self.send(0, b"aaa")
|
|
claims.admit({f"upload:{op.pk}": Claim.EXCLUSIVE}, holder="192.0.2.30:1:1") # a chunk in flight
|
|
op = jobs.cancel(op.pk, self.admin)
|
|
self.assertEqual(op.state, Operation.CANCELLING)
|
|
self.assertTrue(os.path.exists(self.part(op)))
|
|
with self.assertRaises(claims.Busy):
|
|
self.send(1, b"bbb")
|
|
Claim.objects.filter(operation__isnull=True).delete()
|
|
with self.assertRaisesRegex(ValueError, "cancelled"):
|
|
self.send(1, b"bbb")
|
|
op.refresh_from_db()
|
|
self.assertEqual(op.state, Operation.CANCELLED)
|
|
self.assertFalse(os.path.exists(self.part(op)))
|
|
self.assertFalse(Claim.objects.exists())
|
|
|
|
def test_a_cancel_during_a_write_ends_the_upload_after_it(self):
|
|
op = self.send(0, b"aaa")
|
|
with self.cancel_during_write(_UploadTarget), self.assertRaisesRegex(ValueError, "cancelled"):
|
|
self.send(1, b"bbb")
|
|
op.refresh_from_db()
|
|
self.assertEqual(op.state, Operation.CANCELLED)
|
|
self.assertEqual(os.listdir(self.pool), [])
|
|
self.assertFalse(Claim.objects.exists())
|
|
|
|
def test_a_cancel_during_the_last_write_comes_too_late(self):
|
|
with self.cancel_during_write(_UploadTarget):
|
|
self.send(0, b"aaa", total=1, size=3)
|
|
self.assertEqual(self.job().state, Operation.SUCCEEDED)
|
|
self.assertEqual(self.read(self.path()), b"aaa")
|
|
|
|
def test_a_repeated_cancel_retries_the_stop(self):
|
|
op = self.send(0, b"aaa")
|
|
claims.admit({f"upload:{op.pk}": Claim.EXCLUSIVE}, holder="192.0.2.30:1:1") # a request that died
|
|
self.assertEqual(jobs.cancel(op.pk, self.admin).state, Operation.CANCELLING)
|
|
Claim.objects.filter(operation__isnull=True).delete()
|
|
self.assertEqual(jobs.cancel(op.pk, self.admin).state, Operation.CANCELLED)
|
|
self.assertFalse(os.path.exists(self.part(op)))
|
|
|
|
def committed_but_not_renamed(self):
|
|
"""Every chunk is in; the last request died before the rename."""
|
|
self.send(0, b"aaa", total=2, size=6)
|
|
with patch("storages.upload._complete", side_effect=OSError("the request died")):
|
|
with self.assertRaises(OSError):
|
|
self.send(1, b"bbb", total=2, size=6)
|
|
return self.job()
|
|
|
|
def test_a_cancel_of_an_upload_with_every_chunk_in_completes_it(self):
|
|
op = jobs.cancel(self.committed_but_not_renamed().pk, self.admin)
|
|
self.assertEqual(op.state, Operation.SUCCEEDED)
|
|
self.assertEqual(os.listdir(self.pool), ["disk.iso"])
|
|
self.assertEqual(self.read(self.path()), b"aaabbb")
|
|
|
|
def test_a_retry_of_the_last_chunk_completes_a_cancelled_upload_with_every_chunk_in(self):
|
|
op = self.committed_but_not_renamed()
|
|
Operation.objects.filter(pk=op.pk).update(state=Operation.CANCELLING, cancel_requested=True)
|
|
self.assertEqual(self.send(1, b"bbb", total=2, size=6).state, Operation.SUCCEEDED)
|
|
self.assertEqual(self.read(self.path()), b"aaabbb")
|
|
|
|
def test_an_idle_upload_with_every_chunk_in_is_completed(self):
|
|
op = self.committed_but_not_renamed()
|
|
Operation.objects.filter(pk=op.pk).update(heartbeat_at=timezone.now() - timedelta(seconds=3601))
|
|
housekeeping()
|
|
op.refresh_from_db()
|
|
self.assertEqual(op.state, Operation.SUCCEEDED)
|
|
self.assertEqual(self.read(self.path()), b"aaabbb")
|
|
|
|
def test_a_late_retry_of_the_last_chunk_completes_the_upload(self):
|
|
op = self.committed_but_not_renamed()
|
|
Operation.objects.filter(pk=op.pk).update(heartbeat_at=timezone.now() - timedelta(seconds=3601))
|
|
self.assertEqual(self.send(1, b"bbb", total=2, size=6).state, Operation.SUCCEEDED)
|
|
self.assertEqual(self.read(self.path()), b"aaabbb")
|
|
|
|
def test_ending_without_the_compute(self):
|
|
op = self.send(0, b"aaa")
|
|
with self.assertRaises(claims.Busy): # the upload holds the compute
|
|
self.compute.delete()
|
|
Compute.objects.filter(pk=self.compute.pk).delete() # the row gone without Compute.delete()
|
|
op = jobs.cancel(op.pk, self.admin)
|
|
self.assertEqual((op.state, words(op.error)), (Operation.FAILED, "The compute was deleted"))
|
|
|
|
def test_ending_before_anything_was_created_needs_no_connection(self):
|
|
op = jobs.submit("volume.upload", self.admin, {"compute": self.compute.pk, "pool": "default",
|
|
"name": "disk.iso", "size": 9, "chunks": 3}, KEY)
|
|
self.assertEqual(jobs.cancel(op.pk, self.admin).state, Operation.CANCELLED)
|
|
self.opened["storages.upload.wvmStorage"].assert_not_called()
|
|
|
|
def test_a_non_superuser_is_refused(self):
|
|
user = User.objects.create_user("up_user", "up_user@example.com", "pw")
|
|
with self.assertRaises(PermissionDenied):
|
|
self.send(0, b"aaa", user=user)
|
|
self.assertFalse(Operation.objects.exists())
|
|
|
|
def test_an_idle_upload_expires(self):
|
|
op = self.send(0, b"aaa")
|
|
housekeeping() # not idle: nothing is opened
|
|
op.refresh_from_db()
|
|
self.assertEqual(op.state, Operation.RUNNING)
|
|
self.opened["storages.upload.wvmStorage"].assert_not_called()
|
|
Operation.objects.filter(pk=op.pk).update(heartbeat_at=timezone.now() - timedelta(seconds=3601))
|
|
housekeeping()
|
|
op.refresh_from_db()
|
|
self.assertEqual((op.state, words(op.error)), (Operation.FAILED, IDLE))
|
|
self.assertFalse(os.path.exists(self.part(op)))
|
|
self.assertFalse(Claim.objects.exists())
|
|
|
|
def test_an_idle_cancelled_upload_expires_cancelled(self):
|
|
op = self.send(0, b"aaa")
|
|
Operation.objects.filter(pk=op.pk).update(state=Operation.CANCELLING, cancel_requested=True,
|
|
heartbeat_at=timezone.now() - timedelta(seconds=3601))
|
|
housekeeping()
|
|
op.refresh_from_db()
|
|
self.assertEqual((op.state, words(op.error)), (Operation.CANCELLED, ""))
|
|
self.assertFalse(os.path.exists(self.part(op)))
|
|
|
|
def test_a_chunk_after_the_idle_timeout_ends_the_job(self):
|
|
op = self.send(0, b"aaa")
|
|
Operation.objects.filter(pk=op.pk).update(heartbeat_at=timezone.now() - timedelta(seconds=3601))
|
|
with self.assertRaisesRegex(ValueError, "idle"):
|
|
self.send(1, b"bbb")
|
|
op.refresh_from_db()
|
|
self.assertEqual((op.state, words(op.error)), (Operation.FAILED, IDLE))
|
|
self.assertFalse(os.path.exists(self.part(op)))
|
|
|
|
def test_a_symlinked_part_file_is_refused(self):
|
|
op = self.send(0, b"aaa")
|
|
os.unlink(self.part(op))
|
|
os.symlink(self.path("real.iso"), self.part(op))
|
|
with self.assertRaises(PermissionError):
|
|
self.send(1, b"bbb")
|
|
self.assertFalse(os.path.exists(self.path("real.iso")))
|
|
|
|
def test_path_traversal_is_refused(self):
|
|
with patch("os.path.basename", side_effect=lambda x: x):
|
|
with self.assertRaises(PermissionError):
|
|
self.send(0, b"aaa", total=1, size=3, name="../../../evil.iso")
|
|
self.assertFalse(os.path.exists(os.path.join(self.pool, "../../../evil.iso")))
|
|
|
|
def test_the_name_must_be_an_iso(self):
|
|
for name in ("image.img", ".iso", "..", "a\x00.iso"):
|
|
with self.assertRaises(ValueError):
|
|
self.send(0, b"aaa", name=name)
|
|
self.send(0, b"aaa", total=1, size=3, name="image.ISO")
|
|
self.assertTrue(os.path.exists(self.path("image.ISO")))
|
|
|
|
|
|
class SshUploadTests(UploadTestCase):
|
|
base = "/var/lib/libvirt/images"
|
|
|
|
def make_conn(self):
|
|
self.addCleanup(_ssh_pool.close_all)
|
|
conn = MagicMock(conn=CONN_SSH, host="192.0.2.31", login="root", passwd="pw")
|
|
conn.get_target_path.return_value = self.base
|
|
self.files = files = {}
|
|
conn.get_volumes.side_effect = lambda: [p.rsplit("/", 1)[1] for p in files]
|
|
patcher = patch("paramiko.SSHClient")
|
|
self.ssh = patcher.start().return_value
|
|
self.addCleanup(patcher.stop)
|
|
sftp = self.ssh.open_sftp.return_value
|
|
|
|
def lstat(path):
|
|
if path not in files:
|
|
raise FileNotFoundError(path)
|
|
return MagicMock(st_mode=0o100644)
|
|
|
|
class RemoteFile:
|
|
"""The SFTP file of a pipelined write; it is its own client."""
|
|
|
|
MAX_REQUEST_SIZE = 32768
|
|
handle = b"handle"
|
|
sock = MagicMock(recv_ready=lambda: False)
|
|
|
|
def __init__(self, path, mode):
|
|
self.path = path
|
|
if "w" in mode or path not in files:
|
|
files[path] = b""
|
|
self.pos = 0
|
|
|
|
@property
|
|
def sftp(self):
|
|
return self
|
|
|
|
def _async_request(self, fileobj, kind, handle, offset, data):
|
|
buf = bytearray(files[self.path])
|
|
buf[offset:offset + len(data)] = data
|
|
files[self.path] = bytes(buf)
|
|
|
|
def _finish_responses(self, fileobj):
|
|
pass
|
|
|
|
def _check_exception(self):
|
|
pass
|
|
|
|
def seek(self, pos):
|
|
self.pos = pos
|
|
|
|
def truncate(self, size):
|
|
files[self.path] = files[self.path][:size]
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
pass
|
|
|
|
def rename(src, dst):
|
|
if dst in files:
|
|
raise OSError("Failure") # what OpenSSH answers
|
|
files[dst] = files.pop(src)
|
|
|
|
sftp.lstat.side_effect = lstat
|
|
sftp.open.side_effect = RemoteFile
|
|
sftp.rename.side_effect = rename
|
|
sftp.remove.side_effect = lambda path: files.pop(path)
|
|
return conn
|
|
|
|
def part(self, op):
|
|
return f"{self.base}/disk.iso.wvc_upload_{op.pk.hex}.part"
|
|
|
|
def test_three_chunks_over_sftp(self):
|
|
op = self.send(0, b"aaa")
|
|
self.assertEqual(self.ssh.connect.call_args.kwargs["timeout"], 10)
|
|
self.files[self.part(op)] += b"UNCOMMITTED" # a writer died after its write
|
|
self.send(1, b"bbb")
|
|
self.send(2, b"ccc")
|
|
self.send(2, b"ccc")
|
|
op.refresh_from_db()
|
|
self.assertEqual(op.state, Operation.SUCCEEDED)
|
|
self.assertEqual(self.files, {f"{self.base}/disk.iso": b"aaabbbccc"})
|
|
# one connection for the chunks, an SFTP session for each that wrote
|
|
self.ssh.connect.assert_called_once()
|
|
self.assertEqual(self.ssh.open_sftp.call_count, 3)
|
|
|
|
def test_a_destination_that_appears_before_the_rename_fails_the_job(self):
|
|
op = self.send(0, b"aaa")
|
|
self.send(1, b"bbb")
|
|
self.files[f"{self.base}/disk.iso"] = b"mine"
|
|
with self.assertRaises(FileExistsError):
|
|
self.send(2, b"ccc")
|
|
op.refresh_from_db()
|
|
self.assertEqual(op.state, Operation.FAILED)
|
|
self.assertEqual(self.files, {f"{self.base}/disk.iso": b"mine"})
|
|
|
|
def test_a_rename_whose_answer_was_lost_is_completed(self):
|
|
rename = self.ssh.open_sftp.return_value.rename.side_effect
|
|
|
|
def lost(src, dst):
|
|
rename(src, dst)
|
|
raise OSError("Failure")
|
|
|
|
self.ssh.open_sftp.return_value.rename.side_effect = lost
|
|
self.assertEqual(self.send(0, b"aaa", total=1, size=3).state, Operation.SUCCEEDED)
|
|
self.assertEqual(self.files, {f"{self.base}/disk.iso": b"aaa"})
|
|
|
|
def test_cancel_deletes_the_remote_part_file(self):
|
|
op = self.send(0, b"aaa")
|
|
self.assertEqual(jobs.cancel(op.pk, self.admin).state, Operation.CANCELLED)
|
|
self.assertEqual(self.files, {})
|
|
|
|
def test_path_traversal_is_refused(self):
|
|
with patch("os.path.basename", side_effect=lambda x: x):
|
|
with self.assertRaises(PermissionError):
|
|
self.send(0, b"aaa", total=1, size=3, name="../../../evil.iso")
|
|
self.assertEqual(self.files, {})
|
|
|
|
|
|
class LibvirtStreamUploadTests(UploadTestCase):
|
|
def make_conn(self):
|
|
conn = MagicMock(conn=CONN_TCP)
|
|
self.volumes = volumes = {}
|
|
conn.get_volumes.side_effect = lambda: list(volumes)
|
|
|
|
def get_volume(name):
|
|
if name not in volumes:
|
|
error = libvirtError("Storage volume not found")
|
|
error.err = (VIR_ERR_NO_STORAGE_VOL,)
|
|
raise error
|
|
return volumes[name]
|
|
|
|
conn.get_volume.side_effect = get_volume
|
|
|
|
def create(xml, flags):
|
|
from xml.etree import ElementTree
|
|
|
|
root = ElementTree.fromstring(xml)
|
|
name = root.findtext("name")
|
|
self.assertNotIn(name, volumes)
|
|
self.assertEqual((root.findtext("capacity"), root.find("target/format").get("type")), ("9", "raw"))
|
|
volumes[name] = MagicMock()
|
|
volumes[name].delete.side_effect = lambda flags: volumes.pop(name)
|
|
|
|
conn.pool.createXML.side_effect = create
|
|
conn.wvm.newStream.return_value.send.side_effect = len
|
|
return conn
|
|
|
|
def test_chunks_stream_at_their_offsets(self):
|
|
self.send(0, b"aaa")
|
|
self.send(0, b"aaa")
|
|
volume = self.volumes["disk.iso"]
|
|
self.send(1, b"bbb")
|
|
self.send(2, b"ccc")
|
|
self.send(2, b"ccc")
|
|
self.conn.pool.createXML.assert_called_once()
|
|
self.assertEqual([c.args[1:] for c in volume.upload.call_args_list], [(0, 3, 0), (3, 3, 0), (6, 3, 0)])
|
|
self.assertEqual(self.job().state, Operation.SUCCEEDED)
|
|
|
|
def test_tls_uses_the_stream(self):
|
|
self.conn.conn = CONN_TLS
|
|
self.send(0, b"aaaaaaaaa", total=1)
|
|
self.volumes["disk.iso"].upload.assert_called_once()
|
|
|
|
def test_a_failed_stream_aborts_and_the_chunk_is_retried(self):
|
|
stream = self.conn.wvm.newStream.return_value
|
|
stream.send.side_effect = [1, OSError("connection lost")]
|
|
with self.assertRaises(OSError):
|
|
self.send(0, b"aaa")
|
|
stream.abort.assert_called_once()
|
|
stream.send.side_effect = len
|
|
self.send(0, b"aaa")
|
|
self.conn.pool.createXML.assert_called_once()
|
|
self.assertEqual(self.job().progress_done, 3)
|
|
|
|
def test_an_idle_upload_with_every_chunk_in_is_kept(self):
|
|
self.send(0, b"aaa")
|
|
self.send(1, b"bbb")
|
|
with patch("storages.upload._complete", side_effect=OSError("the request died")):
|
|
with self.assertRaises(OSError):
|
|
self.send(2, b"ccc")
|
|
Operation.objects.filter(pk=self.job().pk).update(heartbeat_at=timezone.now() - timedelta(seconds=3601))
|
|
housekeeping()
|
|
self.assertEqual(self.job().state, Operation.SUCCEEDED)
|
|
self.assertEqual(list(self.volumes), ["disk.iso"])
|
|
|
|
def test_a_cancel_during_the_last_write_keeps_the_volume(self):
|
|
with self.cancel_during_write(_VolumeTarget):
|
|
self.send(0, b"aaaaaaaaa", total=1)
|
|
self.assertEqual((self.job().state, list(self.volumes)), (Operation.SUCCEEDED, ["disk.iso"]))
|
|
|
|
def test_a_volume_gone_before_the_completion_fails_the_job(self):
|
|
self.send(0, b"aaa")
|
|
self.send(1, b"bbb")
|
|
with patch("storages.upload._complete", side_effect=OSError("the request died")):
|
|
with self.assertRaises(OSError):
|
|
self.send(2, b"ccc")
|
|
del self.volumes["disk.iso"]
|
|
Operation.objects.filter(pk=self.job().pk).update(heartbeat_at=timezone.now() - timedelta(seconds=3601))
|
|
housekeeping()
|
|
self.assertEqual((self.job().state, words(self.job().error)), (Operation.FAILED, "The uploaded image is missing"))
|
|
|
|
def test_a_failed_volume_lookup_is_raised(self):
|
|
error = libvirtError("connection lost")
|
|
error.err = (VIR_ERR_SYSTEM_ERROR,)
|
|
self.conn.get_volume.side_effect = error
|
|
with self.assertRaises(libvirtError):
|
|
_VolumeTarget(self.conn, "disk.iso", 9).present()
|
|
|
|
def test_cancel_deletes_the_volume(self):
|
|
op = self.send(0, b"aaa")
|
|
self.assertEqual(jobs.cancel(op.pk, self.admin).state, Operation.CANCELLED)
|
|
self.assertEqual(self.volumes, {})
|
|
|
|
def test_an_existing_volume_is_kept(self):
|
|
self.volumes["disk.iso"] = MagicMock()
|
|
with self.assertRaises(FileExistsError):
|
|
self.send(0, b"aaa")
|
|
self.conn.pool.createXML.assert_not_called()
|
|
|
|
|
|
class UploadViewTests(LocalPool, UploadTestCase):
|
|
def setUp(self):
|
|
super().setUp()
|
|
patcher = patch("storages.views.wvmStorage", return_value=self.conn)
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
self.client.force_login(self.admin)
|
|
self.url = reverse("storage", args=[self.compute.pk, "default"])
|
|
|
|
def post(self, index=0, data=b"aaa", total=1, size="3", name="disk.iso", key=KEY, **fields):
|
|
payload = {"iso_upload": "1", "file": SimpleUploadedFile(name, data), "file_name": name,
|
|
"chunk_index": str(index), "total_chunks": str(total), "file_size": size, "upload_id": key}
|
|
payload.update(fields)
|
|
return self.client.post(self.url, {k: v for k, v in payload.items() if v is not None})
|
|
|
|
def test_the_answer_carries_the_job(self):
|
|
first = self.post(0, total=2, size="6")
|
|
self.assertEqual(first.status_code, 200)
|
|
op = self.job()
|
|
self.assertEqual(first.json(), {"success": True, "job": str(op.pk)})
|
|
self.assertEqual(self.client.session["operations_shown"],
|
|
[{"resource": f"pool:{self.compute.pk}:default", "id": str(op.pk)}])
|
|
last = self.post(1, data=b"bbb", total=2, size="6").json()
|
|
self.assertEqual((last["success"], last["job"]), (True, str(op.pk)))
|
|
self.assertEqual(last, {"success": True, "job": str(op.pk)})
|
|
|
|
def test_field_errors(self):
|
|
for fields, status, error in (
|
|
({"name": "notes.txt"}, 400, "Only .iso files"),
|
|
({"file": None}, 400, "No file chunk was submitted"),
|
|
({"file_name": "..\x00.."}, 400, "Invalid file name"),
|
|
({"key": None}, 400, "no valid submission key"),
|
|
({"size": None}, 400, "Invalid file size"),
|
|
({"size": "0"}, 400, "Invalid file size"),
|
|
({"index": 1}, 400, "Invalid chunk index"),
|
|
):
|
|
with self.subTest(fields=fields):
|
|
kwargs = {k: v for k, v in fields.items() if k in ("name", "size", "key", "index")}
|
|
extra = {k: v for k, v in fields.items() if k not in kwargs}
|
|
response = self.post(**kwargs, **extra)
|
|
self.assertEqual(response.status_code, status)
|
|
self.assertIn(error, response.json()["error"])
|
|
self.assertFalse(Operation.objects.exists())
|
|
|
|
def test_a_taken_name(self):
|
|
with open(self.path(), "wb") as stream:
|
|
stream.write(b"mine")
|
|
response = self.post()
|
|
self.assertEqual((response.status_code, response.json()["error"]), (400, "ISO image already exists"))
|
|
|
|
def test_an_unexpected_failure_is_logged(self):
|
|
with patch("storages.views.write_chunk", side_effect=OSError("disk gone")), \
|
|
self.assertLogs("storages.views", "ERROR") as logs:
|
|
response = self.post()
|
|
self.assertEqual((response.status_code, response.json()["error"]), (500, "disk gone"))
|
|
self.assertIn("OSError: disk gone", logs.output[0])
|
|
|
|
def test_a_busy_session_is_retried(self):
|
|
self.post(0, total=2, size="6")
|
|
claims.admit({f"upload:{self.job().pk}": Claim.EXCLUSIVE}, holder="192.0.2.30:1:1")
|
|
response = self.post(1, data=b"bbb", total=2, size="6")
|
|
self.assertEqual((response.status_code, response.json()["error"]),
|
|
(429, "Upload session is busy. Please retry."))
|
|
|
|
def test_a_name_another_upload_holds_is_refused_with_the_reason(self):
|
|
self.post(0, total=2, size="6")
|
|
response = self.post(0, total=2, size="6", key="upload-key-0002")
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertIn("Upload ISO image is running on this resource", response.json()["error"])
|
|
|
|
def test_submission_keys_are_per_user(self):
|
|
self.post(0, total=2, size="6")
|
|
other = User.objects.create_superuser("up_other", "up_other@example.com", "pw")
|
|
self.client.force_login(other)
|
|
response = self.post(1, data=b"bbb", total=2, size="6")
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertIn("not found", response.json()["error"])
|
|
response = self.post(0, total=1, size="3", name="other.iso")
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(Operation.objects.filter(submission_key=KEY).count(), 2)
|
|
self.assertNotEqual(response.json()["job"], str(Operation.objects.get(user=self.admin).pk))
|
|
|