mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-11 12:46:49 +00:00
feat(storages): an ISO upload is a job
The upload session lives in a job instead of flocks and .meta/.done files; the job panel shows and cancels it; an upload idle for an hour is ended and its partial file deleted; the page no longer reloads after an upload.
This commit is contained in:
parent
f2138e54d8
commit
56b4b03500
7 changed files with 1003 additions and 1450 deletions
|
|
@ -1,15 +1,22 @@
|
|||
"""Jobs of the storages app (operations framework)."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.utils.translation import gettext_noop
|
||||
from vrtManager import util
|
||||
from vrtManager.storage import wvmStorage
|
||||
|
||||
from computes.models import Compute
|
||||
from operations import claims
|
||||
from operations import claims, utils
|
||||
from operations.jobs import Kind, Outcome, Plan, register
|
||||
from operations.models import Claim, Operation
|
||||
|
||||
from storages import upload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _storage(params):
|
||||
c = Compute.objects.get(pk=params["compute"])
|
||||
|
|
@ -61,3 +68,51 @@ class CloneVolume(Kind):
|
|||
return Outcome(Operation.UNCERTAIN, error="The copy exists, but the job did not see it finish; "
|
||||
"check it, then resolve this job.")
|
||||
return Outcome(Operation.FAILED, error="The copy was not made")
|
||||
|
||||
|
||||
@register
|
||||
class UploadVolume(Kind):
|
||||
"""An ISO upload: its chunk requests run it (storages.upload)."""
|
||||
|
||||
name = "volume.upload"
|
||||
label = gettext_noop("Upload ISO image")
|
||||
cancellable = True
|
||||
progress_bytes = True
|
||||
runs_in_request = True
|
||||
|
||||
def normalize(self, raw):
|
||||
return {"compute": int(raw["compute"]), "pool": str(raw["pool"]),
|
||||
"name": os.path.basename(str(raw["name"])).strip(), "size": int(raw["size"]),
|
||||
"chunks": int(raw["chunks"])}
|
||||
|
||||
def validate(self, user, params):
|
||||
if not user.is_superuser:
|
||||
raise PermissionDenied
|
||||
name = params["name"]
|
||||
if not name or name in (".", "..") or "\x00" in name:
|
||||
raise ValueError("Invalid file name")
|
||||
if len(name) <= 4 or not name.lower().endswith(".iso"):
|
||||
raise ValueError("Only .iso files can be uploaded")
|
||||
if params["size"] <= 0:
|
||||
raise ValueError("Invalid file size")
|
||||
if params["chunks"] < 1:
|
||||
raise ValueError("Invalid chunk index or total chunks")
|
||||
|
||||
def plan(self, user, params):
|
||||
compute, stg = _storage(params)
|
||||
stg.refresh()
|
||||
if params["name"] in stg.get_volumes():
|
||||
raise FileExistsError(f"The pool has a volume named {params['name']}")
|
||||
pool = claims.pool_key(compute.pk, params["pool"])
|
||||
return Plan(claims={claims.compute_key(compute.pk): Claim.SHARED, pool: Claim.SHARED,
|
||||
claims.volume_key(compute.pk, params["pool"], params["name"]): Claim.EXCLUSIVE},
|
||||
resource=pool, label=f"{compute.name}/{params['pool']}", intent={"chunk": 0, "created": False})
|
||||
|
||||
def cancel(self, op):
|
||||
try:
|
||||
upload.end(op, Operation.CANCELLED)
|
||||
except Exception as err: # the next chunk or the expiry ends it
|
||||
logger.error("cancelling upload %s failed: %s", op.pk, utils.safe_text(err))
|
||||
|
||||
def expire(self, op):
|
||||
upload.end(op, Operation.FAILED, upload.IDLE)
|
||||
|
|
|
|||
|
|
@ -304,6 +304,9 @@
|
|||
processData: false,
|
||||
contentType: false,
|
||||
success: function(data) {
|
||||
if (chunkIndex === 0) {
|
||||
window.resourceJobPanel.track(data.job);
|
||||
}
|
||||
retryCount = 0;
|
||||
chunkIndex++;
|
||||
pendingUpload.chunkIndex = chunkIndex;
|
||||
|
|
@ -311,16 +314,13 @@
|
|||
progressBar.width(percentComplete + '%').attr('aria-valuenow', percentComplete).text(percentComplete + '%');
|
||||
uploadStatusText.text("{% trans 'Uploading...'|escapejs %} " + percentComplete + '%');
|
||||
|
||||
if (data.reload) {
|
||||
pendingUpload = null;
|
||||
uploadInProgress = false;
|
||||
location.reload();
|
||||
} else if (chunkIndex < totalChunks) {
|
||||
if (chunkIndex < totalChunks) {
|
||||
uploadChunk();
|
||||
} else {
|
||||
pendingUpload = null;
|
||||
uploadInProgress = false;
|
||||
uploadStatus.removeClass('d-flex').addClass('d-none');
|
||||
progressContainer.hide();
|
||||
uploadButton.prop('disabled', false);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ def upload(client):
|
|||
target = _UploadTarget(MagicMock(conn=CONN_SSH, host="192.0.2.1"), "/pool", "x.iso", "id")
|
||||
target.sftp = MagicMock(open=lambda path, mode: paramiko.SFTPFile(client, b"handle", "w"))
|
||||
data = bytes(range(256)) * (SIZE // 256)
|
||||
return target.write_chunk(SimpleUploadedFile("x.iso", data), 0, new=True), data
|
||||
return target.write(SimpleUploadedFile("x.iso", data), 0, new=True), data
|
||||
|
||||
|
||||
class PipelinedWriteTestCase(unittest.TestCase):
|
||||
|
|
|
|||
671
storages/tests/test_upload_job.py
Normal file
671
storages/tests/test_upload_job.py
Normal file
|
|
@ -0,0 +1,671 @@
|
|||
"""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.test import 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 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 send(self, index, data, total=3, size=9, name="disk.iso", key=KEY, user=None):
|
||||
return write_chunk(user or self.admin, 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, 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.admin, 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")
|
||||
self.compute.delete()
|
||||
op = jobs.cancel(op.pk, self.admin)
|
||||
self.assertEqual((op.state, 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, 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, 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, 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, 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_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))
|
||||
|
||||
|
|
@ -1,874 +1,16 @@
|
|||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import RequestFactory, TestCase
|
||||
from django.contrib.messages import get_messages
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages import get_messages
|
||||
from django.test import RequestFactory, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from computes.models import Compute
|
||||
from operations import claims
|
||||
from storages.upload import (
|
||||
handle_uploaded_file,
|
||||
_acquire_upload_lock,
|
||||
_atomic_finalize_ssh,
|
||||
_cleanup_stale_local_uploads,
|
||||
_get_upload_lock_directory,
|
||||
_is_session_completed,
|
||||
_sftp_replace_file,
|
||||
_ssh_pool,
|
||||
)
|
||||
from vrtManager.connection import CONN_SOCKET, CONN_SSH, CONN_TCP, CONN_TLS
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class LibvirtUploadTests(TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.lock_patch = patch("storages.upload._get_upload_lock_directory", return_value=self.temp_dir)
|
||||
self.lock_patch.start()
|
||||
self.conn = MagicMock()
|
||||
self.conn.conn = CONN_TCP
|
||||
self.volumes = {}
|
||||
self.conn.get_volumes.side_effect = lambda: list(self.volumes)
|
||||
self.conn.get_volume.side_effect = lambda name: self.volumes[name]
|
||||
|
||||
def create_volume(xml, flags):
|
||||
from xml.etree import ElementTree
|
||||
root = ElementTree.fromstring(xml)
|
||||
name = root.findtext("name")
|
||||
self.assertNotIn(name, self.volumes)
|
||||
self.assertEqual(root.find("capacity").get("unit"), "bytes")
|
||||
self.assertEqual(root.find("target/format").get("type"), "raw")
|
||||
self.volumes[name] = MagicMock()
|
||||
|
||||
self.conn.pool.createXML.side_effect = create_volume
|
||||
self.conn.wvm.newStream.return_value.send.side_effect = lambda data: len(data)
|
||||
|
||||
def tearDown(self):
|
||||
self.lock_patch.stop()
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def upload(self, data, index=0, total=1, upload_id="tcp-session", file_size=None):
|
||||
return handle_uploaded_file(
|
||||
self.conn, "/var/lib/libvirt/images", "ubuntu.iso",
|
||||
SimpleUploadedFile("ubuntu.iso", data), index == total - 1,
|
||||
chunk_index=index, total_chunks=total, upload_id=upload_id,
|
||||
user_id=1, compute_id=2, pool="default", file_size=file_size,
|
||||
)
|
||||
|
||||
def test_tcp_upload_retries_and_stream_offsets(self):
|
||||
self.upload(b"abc", index=0, total=2, file_size=5)
|
||||
self.upload(b"abc", index=0, total=2, file_size=5)
|
||||
self.upload(b"de", index=1, total=2, file_size=5)
|
||||
self.upload(b"de", index=1, total=2, file_size=5)
|
||||
self.conn.pool.createXML.assert_called_once()
|
||||
self.assertEqual(self.volumes["ubuntu.iso"].upload.call_count, 2)
|
||||
self.assertEqual(self.volumes["ubuntu.iso"].upload.call_args.args[1:], (3, 2, 0))
|
||||
self.assertEqual(self.conn.wvm.newStream.return_value.finish.call_count, 2)
|
||||
|
||||
def test_tls_uses_libvirt_stream(self):
|
||||
self.conn.conn = CONN_TLS
|
||||
self.upload(b"iso", file_size=3)
|
||||
self.volumes["ubuntu.iso"].upload.assert_called_once()
|
||||
|
||||
def test_existing_volume_is_preserved(self):
|
||||
self.volumes["ubuntu.iso"] = MagicMock()
|
||||
with self.assertRaises(FileExistsError):
|
||||
self.upload(b"iso", file_size=3)
|
||||
self.conn.pool.createXML.assert_not_called()
|
||||
|
||||
def test_size_and_sequence_are_validated(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.upload(b"iso", file_size=None)
|
||||
with self.assertRaises(ValueError):
|
||||
self.upload(b"iso", file_size=4)
|
||||
self.conn.pool.createXML.assert_not_called()
|
||||
self.upload(b"ab", index=0, total=2, file_size=4)
|
||||
with self.assertRaises(ValueError):
|
||||
self.upload(b"cd", index=1, total=2, file_size=5)
|
||||
with self.assertRaises(ValueError):
|
||||
self.upload(b"cd", index=1, total=3, file_size=4)
|
||||
|
||||
def test_stream_failure_aborts_and_can_retry(self):
|
||||
stream = self.conn.wvm.newStream.return_value
|
||||
stream.send.side_effect = [1, OSError("connection lost")]
|
||||
with self.assertRaises(OSError):
|
||||
self.upload(b"iso", file_size=3)
|
||||
stream.abort.assert_called_once()
|
||||
self.assertEqual(stream.finish.call_count, 0)
|
||||
stream.send.side_effect = lambda data: len(data)
|
||||
self.upload(b"iso", file_size=3)
|
||||
self.conn.pool.createXML.assert_called_once()
|
||||
|
||||
|
||||
|
||||
|
||||
class StorageUploadSecurityTests(TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.mock_socket_conn = MagicMock()
|
||||
self.mock_socket_conn.conn = CONN_SOCKET
|
||||
|
||||
self.mock_ssh_conn = MagicMock()
|
||||
self.mock_ssh_conn.conn = CONN_SSH
|
||||
self.mock_ssh_conn.host = "127.0.0.1"
|
||||
self.mock_ssh_conn.login = "test"
|
||||
self.mock_ssh_conn.passwd = "test"
|
||||
self.addCleanup(_ssh_pool.close_all)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_handle_uploaded_file_missing_or_invalid_upload_id(self):
|
||||
dummy_chunk = SimpleUploadedFile("test.iso", b"data")
|
||||
# Missing upload_id
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, "test.iso", dummy_chunk, is_last_chunk=True)
|
||||
self.assertIn("Missing required upload_id", str(ctx.exception))
|
||||
|
||||
# Invalid upload_id format
|
||||
for bad_id in ["", " ", "sess/../id", "id with spaces", "a" * 65]:
|
||||
with self.assertRaises(ValueError):
|
||||
handle_uploaded_file(
|
||||
self.mock_socket_conn,
|
||||
self.temp_dir,
|
||||
"test.iso",
|
||||
dummy_chunk,
|
||||
is_last_chunk=True,
|
||||
upload_id=bad_id,
|
||||
)
|
||||
|
||||
def test_handle_uploaded_file_invalid_names(self):
|
||||
dummy_chunk = SimpleUploadedFile("test.iso", b"data")
|
||||
invalid_names = ["", " ", ".", "..", "test\x00evil.iso", "../evil.iso", "../../etc/shadow"]
|
||||
|
||||
for bad_name in invalid_names:
|
||||
clean_base = os.path.basename(bad_name).strip()
|
||||
if not clean_base or clean_base in (".", "..") or "\x00" in clean_base:
|
||||
with self.assertRaises(ValueError):
|
||||
handle_uploaded_file(
|
||||
self.mock_socket_conn,
|
||||
self.temp_dir,
|
||||
bad_name,
|
||||
dummy_chunk,
|
||||
is_last_chunk=True,
|
||||
upload_id="sess-valid-1",
|
||||
)
|
||||
|
||||
def test_only_iso_extension_is_accepted(self):
|
||||
chunk = SimpleUploadedFile("image.img", b"not checked")
|
||||
for conn in (self.mock_socket_conn, self.mock_ssh_conn):
|
||||
with self.assertRaisesRegex(ValueError, r"Only \.iso files"):
|
||||
handle_uploaded_file(
|
||||
conn, self.temp_dir, "image.img", chunk,
|
||||
is_last_chunk=True, upload_id="iso-extension-test",
|
||||
)
|
||||
handle_uploaded_file(
|
||||
self.mock_socket_conn, self.temp_dir, "image.ISO",
|
||||
SimpleUploadedFile("image.ISO", b"contents are not inspected"),
|
||||
is_last_chunk=True, upload_id="uppercase-iso-test",
|
||||
)
|
||||
self.assertTrue(os.path.exists(os.path.join(self.temp_dir, "image.ISO")))
|
||||
|
||||
def test_handle_uploaded_file_successful_socket_upload(self):
|
||||
dummy_chunk = SimpleUploadedFile("install.iso", b"ISODATA_BLOCK_123")
|
||||
file_name = "install.iso"
|
||||
upload_id = "sess-install-1"
|
||||
|
||||
# Chunk 1 (is_last_chunk=False, chunk_index=0, total_chunks=2)
|
||||
handle_uploaded_file(
|
||||
self.mock_socket_conn,
|
||||
self.temp_dir,
|
||||
file_name,
|
||||
dummy_chunk,
|
||||
is_last_chunk=False,
|
||||
chunk_index=0,
|
||||
total_chunks=2,
|
||||
upload_id=upload_id,
|
||||
)
|
||||
part_path = os.path.join(self.temp_dir, f"{file_name}.wvc_upload_{upload_id}.part")
|
||||
final_path = os.path.join(self.temp_dir, file_name)
|
||||
self.assertTrue(os.path.exists(part_path))
|
||||
self.assertFalse(os.path.exists(final_path))
|
||||
|
||||
# Chunk 2 (is_last_chunk=True, chunk_index=1, total_chunks=2)
|
||||
dummy_chunk2 = SimpleUploadedFile("install.iso", b"_PART2")
|
||||
handle_uploaded_file(
|
||||
self.mock_socket_conn,
|
||||
self.temp_dir,
|
||||
file_name,
|
||||
dummy_chunk2,
|
||||
is_last_chunk=True,
|
||||
chunk_index=1,
|
||||
total_chunks=2,
|
||||
upload_id=upload_id,
|
||||
)
|
||||
self.assertFalse(os.path.exists(part_path))
|
||||
self.assertTrue(os.path.exists(final_path))
|
||||
|
||||
with open(final_path, "rb") as f:
|
||||
self.assertEqual(f.read(), b"ISODATA_BLOCK_123_PART2")
|
||||
|
||||
def test_handle_uploaded_file_idempotent_retry(self):
|
||||
file_name = "retry_test.iso"
|
||||
upload_id = "sess-retry-123"
|
||||
c0 = SimpleUploadedFile(file_name, b"CHUNK0_")
|
||||
c1 = SimpleUploadedFile(file_name, b"CHUNK1_")
|
||||
c2 = SimpleUploadedFile(file_name, b"CHUNK2")
|
||||
|
||||
# Chunk 0 initial
|
||||
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c0, is_last_chunk=False, chunk_index=0, total_chunks=3, upload_id=upload_id)
|
||||
# Chunk 0 retry (idempotent no-op!)
|
||||
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c0, is_last_chunk=False, chunk_index=0, total_chunks=3, upload_id=upload_id)
|
||||
|
||||
# Chunk 1 initial
|
||||
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c1, is_last_chunk=False, chunk_index=1, total_chunks=3, upload_id=upload_id)
|
||||
# Chunk 1 retry (idempotent no-op!)
|
||||
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c1, is_last_chunk=False, chunk_index=1, total_chunks=3, upload_id=upload_id)
|
||||
|
||||
# Chunk 2 initial (final chunk)
|
||||
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c2, is_last_chunk=True, chunk_index=2, total_chunks=3, upload_id=upload_id)
|
||||
|
||||
# Chunk 2 retry (idempotent no-op, file already finalized!)
|
||||
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c2, is_last_chunk=True, chunk_index=2, total_chunks=3, upload_id=upload_id)
|
||||
|
||||
final_file = os.path.join(self.temp_dir, file_name)
|
||||
self.assertTrue(os.path.exists(final_file))
|
||||
with open(final_file, "rb") as f:
|
||||
self.assertEqual(f.read(), b"CHUNK0_CHUNK1_CHUNK2")
|
||||
|
||||
def test_handle_uploaded_file_interrupted_finalization(self):
|
||||
file_name = "interrupted.iso"
|
||||
upload_id = "sess-interrupted-1"
|
||||
c0 = SimpleUploadedFile(file_name, b"PART1")
|
||||
c1 = SimpleUploadedFile(file_name, b"PART2")
|
||||
|
||||
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c0, is_last_chunk=False, chunk_index=0, total_chunks=2, upload_id=upload_id)
|
||||
|
||||
# Simulate state where chunk 1 was recorded in .meta and .part, but finalization was interrupted
|
||||
meta_file = os.path.join(self.temp_dir, f"{file_name}.wvc_upload_{upload_id}.meta")
|
||||
part_file = os.path.join(self.temp_dir, f"{file_name}.wvc_upload_{upload_id}.part")
|
||||
with open(part_file, "ab") as f:
|
||||
f.write(b"PART2")
|
||||
import json
|
||||
with open(meta_file, "w") as f:
|
||||
json.dump({
|
||||
"file_name": file_name,
|
||||
"total_chunks": 2,
|
||||
"expected_chunk": 2,
|
||||
"current_size": 10,
|
||||
"created_at": time.time(),
|
||||
"updated_at": time.time()
|
||||
}, f)
|
||||
|
||||
# Client retries final chunk (chunk 1)
|
||||
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c1, is_last_chunk=True, chunk_index=1, total_chunks=2, upload_id=upload_id)
|
||||
|
||||
final_file = os.path.join(self.temp_dir, file_name)
|
||||
self.assertTrue(os.path.exists(final_file))
|
||||
with open(final_file, "rb") as f:
|
||||
self.assertEqual(f.read(), b"PART1PART2")
|
||||
|
||||
|
||||
def test_handle_uploaded_file_inconsistent_total_chunks(self):
|
||||
file_name = "inconsistent.iso"
|
||||
upload_id = "sess-inc-1"
|
||||
c0 = SimpleUploadedFile(file_name, b"part0")
|
||||
c1 = SimpleUploadedFile(file_name, b"part1")
|
||||
|
||||
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c0, is_last_chunk=False, chunk_index=0, total_chunks=3, upload_id=upload_id)
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c1, is_last_chunk=False, chunk_index=1, total_chunks=4, upload_id=upload_id)
|
||||
self.assertIn("Inconsistent total_chunks", str(ctx.exception))
|
||||
|
||||
def test_handle_uploaded_file_missing_previous_chunk(self):
|
||||
dummy_chunk = SimpleUploadedFile("broken.iso", b"data")
|
||||
with self.assertRaises(ValueError):
|
||||
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, "broken.iso", dummy_chunk, is_last_chunk=False, chunk_index=1, total_chunks=2, upload_id="sess-broken-1")
|
||||
|
||||
def test_handle_uploaded_file_destination_exists_raises(self):
|
||||
file_name = "existing.iso"
|
||||
final_path = os.path.join(self.temp_dir, file_name)
|
||||
with open(final_path, "wb") as f:
|
||||
f.write(b"already exists")
|
||||
|
||||
dummy_chunk = SimpleUploadedFile(file_name, b"new data")
|
||||
with self.assertRaises(Exception):
|
||||
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, dummy_chunk, is_last_chunk=False, chunk_index=0, total_chunks=2, upload_id="sess-exist-1")
|
||||
|
||||
def test_handle_uploaded_file_symlink_defense(self):
|
||||
file_name = "symlink_test.iso"
|
||||
upload_id = "sess-symlink-1"
|
||||
target_file = os.path.join(self.temp_dir, "real.iso")
|
||||
with open(target_file, "wb") as f:
|
||||
f.write(b"real content")
|
||||
|
||||
symlink_part = os.path.join(self.temp_dir, f"{file_name}.wvc_upload_{upload_id}.part")
|
||||
os.symlink(target_file, symlink_part)
|
||||
|
||||
dummy_chunk = SimpleUploadedFile(file_name, b"malicious chunk")
|
||||
with self.assertRaises(PermissionError):
|
||||
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, dummy_chunk, is_last_chunk=False, chunk_index=0, total_chunks=2, upload_id=upload_id)
|
||||
|
||||
def test_handle_uploaded_file_jail_containment(self):
|
||||
dummy_chunk = SimpleUploadedFile("test.iso", b"data")
|
||||
with patch("os.path.basename", side_effect=lambda x: x):
|
||||
with self.assertRaises(PermissionError):
|
||||
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, "../../../evil.iso", dummy_chunk, is_last_chunk=True, upload_id="sess-jail-1")
|
||||
|
||||
def test_cleanup_stale_local_uploads_strict_regex(self):
|
||||
unrelated_files = [
|
||||
"unrelated.part",
|
||||
"important.lock",
|
||||
"data.meta",
|
||||
"short.123.part",
|
||||
"backup.abcdefgh.meta",
|
||||
"disk.12345678.part",
|
||||
]
|
||||
for fname in unrelated_files:
|
||||
p = os.path.join(self.temp_dir, fname)
|
||||
with open(p, "wb") as f:
|
||||
f.write(b"keep me")
|
||||
old_time = time.time() - 172800
|
||||
os.utime(p, (old_time, old_time))
|
||||
|
||||
stale_part = os.path.join(self.temp_dir, "ubuntu.iso.wvc_upload_sess-12345678.part")
|
||||
stale_meta = os.path.join(self.temp_dir, "ubuntu.iso.wvc_upload_sess-12345678.meta")
|
||||
fresh_part = os.path.join(self.temp_dir, "ubuntu.iso.wvc_upload_sess-87654321.part")
|
||||
|
||||
with open(stale_part, "wb") as f:
|
||||
f.write(b"stale part")
|
||||
import json
|
||||
with open(stale_meta, "w") as f:
|
||||
json.dump({"upload_id": "sess-12345678", "expected_chunk": 1}, f)
|
||||
with open(fresh_part, "wb") as f:
|
||||
f.write(b"fresh part")
|
||||
|
||||
old_time = time.time() - 172800
|
||||
os.utime(stale_part, (old_time, old_time))
|
||||
os.utime(stale_meta, (old_time, old_time))
|
||||
|
||||
_cleanup_stale_local_uploads(self.temp_dir, max_age_seconds=86400)
|
||||
|
||||
for fname in unrelated_files:
|
||||
self.assertTrue(os.path.exists(os.path.join(self.temp_dir, fname)), f"{fname} was unexpectedly deleted")
|
||||
self.assertTrue(os.path.exists(fresh_part))
|
||||
|
||||
self.assertFalse(os.path.exists(stale_part))
|
||||
self.assertFalse(os.path.exists(stale_meta))
|
||||
|
||||
def test_sftp_replace_file_requires_atomic_replace(self):
|
||||
mock_sftp = MagicMock()
|
||||
mock_sftp.posix_rename.return_value = None
|
||||
_sftp_replace_file(mock_sftp, "/tmp/src", "/tmp/dst")
|
||||
mock_sftp.posix_rename.assert_called_once_with("/tmp/src", "/tmp/dst")
|
||||
mock_sftp.rename.assert_not_called()
|
||||
|
||||
mock_sftp.reset_mock()
|
||||
mock_sftp.posix_rename.side_effect = IOError("posix_rename unsupported")
|
||||
with self.assertRaises(OSError):
|
||||
_sftp_replace_file(mock_sftp, "/tmp/src", "/tmp/dst")
|
||||
mock_sftp.posix_rename.assert_called_once_with("/tmp/src", "/tmp/dst")
|
||||
mock_sftp.remove.assert_not_called()
|
||||
mock_sftp.rename.assert_not_called()
|
||||
|
||||
# posix_rename with other error must NOT fallback to remove
|
||||
mock_sftp.reset_mock()
|
||||
mock_sftp.posix_rename.side_effect = IOError("permission denied")
|
||||
with self.assertRaises(IOError):
|
||||
_sftp_replace_file(mock_sftp, "/tmp/src", "/tmp/dst")
|
||||
mock_sftp.remove.assert_not_called()
|
||||
|
||||
mock_sftp = MagicMock(spec=["remove", "rename"])
|
||||
with self.assertRaises(OSError):
|
||||
_sftp_replace_file(mock_sftp, "/tmp/src", "/tmp/dst")
|
||||
mock_sftp.remove.assert_not_called()
|
||||
mock_sftp.rename.assert_not_called()
|
||||
|
||||
mock_ssh = MagicMock()
|
||||
stdout = MagicMock()
|
||||
stdout.channel.recv_exit_status.return_value = 0
|
||||
mock_ssh.exec_command.return_value = (MagicMock(), stdout, MagicMock())
|
||||
_sftp_replace_file(mock_sftp, "/tmp/src", "/tmp/dst", ssh=mock_ssh)
|
||||
mock_sftp.remove.assert_not_called()
|
||||
|
||||
@patch("storages.upload.time.sleep")
|
||||
@patch("storages.upload.fcntl.flock", side_effect=BlockingIOError)
|
||||
@patch("storages.upload.LOCK_TIMEOUT_SECONDS", 0)
|
||||
def test_upload_lock_times_out(self, _flock, _sleep):
|
||||
with self.assertRaises(TimeoutError):
|
||||
_acquire_upload_lock(123)
|
||||
|
||||
def test_atomic_finalize_ssh_success_and_overwrite_prevention(self):
|
||||
mock_sftp = MagicMock()
|
||||
|
||||
_atomic_finalize_ssh(mock_sftp, "/tmp/temp.part", "/tmp/final.iso")
|
||||
mock_sftp.rename.assert_called_once_with("/tmp/temp.part", "/tmp/final.iso")
|
||||
|
||||
# A destination created concurrently remains untouched.
|
||||
mock_sftp.rename.side_effect = IOError("File exists")
|
||||
mock_sftp.reset_mock()
|
||||
with self.assertRaises(FileExistsError):
|
||||
_atomic_finalize_ssh(mock_sftp, "/tmp/temp.part", "/tmp/final.iso")
|
||||
mock_sftp.remove.assert_not_called()
|
||||
|
||||
def test_is_session_completed_crash_recovery(self):
|
||||
upload_id = "sess-crash-rec-1"
|
||||
file_name = "recovered.iso"
|
||||
final_file = os.path.join(self.temp_dir, file_name)
|
||||
with open(final_file, "wb") as f:
|
||||
f.write(b"COMPLETE_CONTENT_12345")
|
||||
file_size = os.path.getsize(final_file)
|
||||
|
||||
meta_file = os.path.join(self.temp_dir, f"{file_name}.wvc_upload_{upload_id}.meta")
|
||||
import json
|
||||
with open(meta_file, "w") as mf:
|
||||
json.dump({
|
||||
"file_name": file_name,
|
||||
"expected_chunk": 3,
|
||||
"total_chunks": 3,
|
||||
"current_size": file_size,
|
||||
"created_at": time.time(),
|
||||
"updated_at": time.time(),
|
||||
}, mf)
|
||||
|
||||
# No .done marker exists yet; _is_session_completed should recover the session from .meta
|
||||
lock_dir = _get_upload_lock_directory()
|
||||
marker_path = os.path.join(lock_dir, f"upload_{upload_id}.done")
|
||||
if os.path.exists(marker_path):
|
||||
os.unlink(marker_path)
|
||||
|
||||
recovered = _is_session_completed(upload_id, file_name, path=self.temp_dir)
|
||||
self.assertTrue(recovered)
|
||||
# .meta should now be cleaned up and .done marker created
|
||||
self.assertFalse(os.path.exists(meta_file))
|
||||
self.assertTrue(os.path.exists(marker_path))
|
||||
if os.path.exists(marker_path):
|
||||
os.unlink(marker_path)
|
||||
|
||||
def test_lock_file_not_unlinked_on_completion(self):
|
||||
upload_id = "sess-lock-persist-1"
|
||||
file_name = "lock_persist.iso"
|
||||
dummy_chunk = SimpleUploadedFile(file_name, b"data")
|
||||
handle_uploaded_file(
|
||||
self.mock_socket_conn,
|
||||
self.temp_dir,
|
||||
file_name,
|
||||
dummy_chunk,
|
||||
is_last_chunk=True,
|
||||
chunk_index=0,
|
||||
total_chunks=1,
|
||||
upload_id=upload_id,
|
||||
)
|
||||
lock_dir = _get_upload_lock_directory()
|
||||
lock_path = os.path.join(lock_dir, f"upload_session_{upload_id}.lock")
|
||||
self.assertTrue(os.path.exists(lock_path))
|
||||
|
||||
def test_destination_exists_requires_completed_session(self):
|
||||
file_name = "already_here.iso"
|
||||
dest_path = os.path.join(self.temp_dir, file_name)
|
||||
with open(dest_path, "wb") as f:
|
||||
f.write(b"existing content")
|
||||
|
||||
dummy_chunk = SimpleUploadedFile(file_name, b"new chunk")
|
||||
with self.assertRaises(FileExistsError):
|
||||
handle_uploaded_file(
|
||||
self.mock_socket_conn,
|
||||
self.temp_dir,
|
||||
file_name,
|
||||
dummy_chunk,
|
||||
is_last_chunk=True,
|
||||
chunk_index=0,
|
||||
total_chunks=1,
|
||||
upload_id="sess-rogue-123",
|
||||
)
|
||||
|
||||
@patch("paramiko.SSHClient")
|
||||
def test_sftp_upload_multi_chunk_and_retry(self, mock_ssh_cls):
|
||||
mock_ssh = MagicMock()
|
||||
mock_ssh_cls.return_value = mock_ssh
|
||||
mock_sftp = MagicMock()
|
||||
mock_ssh.open_sftp.return_value = mock_sftp
|
||||
|
||||
remote_files = {}
|
||||
|
||||
def mock_lstat(path):
|
||||
if path in remote_files:
|
||||
stat_obj = MagicMock()
|
||||
stat_obj.st_mode = 0o100644
|
||||
return stat_obj
|
||||
raise FileNotFoundError(f"{path} not found")
|
||||
|
||||
mock_sftp.lstat.side_effect = mock_lstat
|
||||
|
||||
class FakeRemoteFile:
|
||||
def __init__(self, path, mode):
|
||||
self.path = path
|
||||
self.mode = mode
|
||||
if "w" in mode or path not in remote_files:
|
||||
remote_files[path] = b""
|
||||
self.buf = bytearray(remote_files[path])
|
||||
self.pos = 0
|
||||
|
||||
def write(self, data):
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf-8")
|
||||
self.buf[self.pos:self.pos + len(data)] = data
|
||||
self.pos += len(data)
|
||||
remote_files[self.path] = bytes(self.buf)
|
||||
|
||||
def read(self):
|
||||
return bytes(self.buf)
|
||||
|
||||
# the SFTP client calls of a pipelined write: the file is its own client
|
||||
MAX_REQUEST_SIZE = 32768
|
||||
handle = b"handle"
|
||||
sock = MagicMock(recv_ready=lambda: False)
|
||||
|
||||
@property
|
||||
def sftp(self):
|
||||
return self
|
||||
|
||||
def _async_request(self, fileobj, kind, handle, offset, data):
|
||||
self.seek(offset)
|
||||
self.write(data)
|
||||
|
||||
def _finish_responses(self, fileobj):
|
||||
pass
|
||||
|
||||
def _check_exception(self):
|
||||
pass
|
||||
|
||||
def seek(self, pos):
|
||||
self.pos = pos
|
||||
|
||||
def truncate(self, size):
|
||||
del self.buf[size:]
|
||||
remote_files[self.path] = bytes(self.buf)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
mock_sftp.open.side_effect = lambda p, m: FakeRemoteFile(p, m)
|
||||
|
||||
def mock_rename(src, dst):
|
||||
if src not in remote_files:
|
||||
raise FileNotFoundError(f"{src} not found")
|
||||
remote_files[dst] = remote_files.pop(src)
|
||||
|
||||
mock_sftp.rename.side_effect = mock_rename
|
||||
mock_sftp.posix_rename.side_effect = mock_rename
|
||||
mock_sftp.remove.side_effect = lambda p: remote_files.pop(p, None)
|
||||
|
||||
def mock_exec_command(command, **kwargs):
|
||||
if "os.replace(" in command:
|
||||
import shlex
|
||||
src, dst = shlex.split(command)[-2:]
|
||||
mock_rename(src, dst)
|
||||
if "os.link(" in command:
|
||||
import shlex
|
||||
src, dst = shlex.split(command)[-2:]
|
||||
if dst in remote_files:
|
||||
raise FileExistsError(dst)
|
||||
remote_files[dst] = remote_files[src]
|
||||
stdout = MagicMock()
|
||||
stdout.channel.recv_exit_status.return_value = 0
|
||||
return MagicMock(), stdout, MagicMock()
|
||||
|
||||
mock_ssh.exec_command.side_effect = mock_exec_command
|
||||
|
||||
upload_id = "sess-sftp-multi-1"
|
||||
file_name = "debian.iso"
|
||||
c0 = SimpleUploadedFile(file_name, b"DEBIAN_CHUNK0_")
|
||||
c1 = SimpleUploadedFile(file_name, b"DEBIAN_CHUNK1")
|
||||
|
||||
handle_uploaded_file(
|
||||
self.mock_ssh_conn,
|
||||
"/var/lib/libvirt/images",
|
||||
file_name,
|
||||
c0,
|
||||
is_last_chunk=False,
|
||||
chunk_index=0,
|
||||
total_chunks=2,
|
||||
upload_id=upload_id,
|
||||
)
|
||||
self.assertEqual(mock_ssh.connect.call_args.kwargs["timeout"], 10)
|
||||
mock_sftp.get_channel.return_value.settimeout.assert_called_with(30)
|
||||
|
||||
# Simulate bytes written before a worker crashed without advancing metadata.
|
||||
part_remote = f"/var/lib/libvirt/images/{file_name}.wvc_upload_{upload_id}.part"
|
||||
remote_files[part_remote] += b"UNCOMMITTED"
|
||||
|
||||
handle_uploaded_file(
|
||||
self.mock_ssh_conn,
|
||||
"/var/lib/libvirt/images",
|
||||
file_name,
|
||||
c1,
|
||||
is_last_chunk=True,
|
||||
chunk_index=1,
|
||||
total_chunks=2,
|
||||
upload_id=upload_id,
|
||||
)
|
||||
|
||||
final_remote = "/var/lib/libvirt/images/debian.iso"
|
||||
self.assertIn(final_remote, remote_files)
|
||||
self.assertEqual(remote_files[final_remote], b"DEBIAN_CHUNK0_DEBIAN_CHUNK1")
|
||||
|
||||
# Retry Chunk 1
|
||||
handle_uploaded_file(
|
||||
self.mock_ssh_conn,
|
||||
"/var/lib/libvirt/images",
|
||||
file_name,
|
||||
c1,
|
||||
is_last_chunk=True,
|
||||
chunk_index=1,
|
||||
total_chunks=2,
|
||||
upload_id=upload_id,
|
||||
)
|
||||
self.assertEqual(remote_files[final_remote], b"DEBIAN_CHUNK0_DEBIAN_CHUNK1")
|
||||
# one connection for the three requests, an SFTP session for each
|
||||
mock_ssh.connect.assert_called_once()
|
||||
self.assertEqual(mock_ssh.open_sftp.call_count, 3)
|
||||
|
||||
def test_handle_uploaded_file_ssh_traversal_check(self):
|
||||
dummy_chunk = SimpleUploadedFile("test.iso", b"data")
|
||||
with patch("os.path.basename", side_effect=lambda x: x):
|
||||
with self.assertRaises(PermissionError):
|
||||
handle_uploaded_file(self.mock_ssh_conn, "/var/lib/libvirt/images", "../../../evil.iso", dummy_chunk, is_last_chunk=True, upload_id="sess-ssh-1")
|
||||
|
||||
|
||||
def test_interrupted_first_chunk_can_be_retried_without_appending_partial_data(self):
|
||||
file_name = "first-chunk-retry.iso"
|
||||
chunk = SimpleUploadedFile(file_name, b"complete-data")
|
||||
|
||||
def interrupted():
|
||||
yield b"partial"
|
||||
raise OSError("Interrupted first write")
|
||||
|
||||
chunk.chunks = interrupted
|
||||
with self.assertRaises(OSError):
|
||||
handle_uploaded_file(
|
||||
self.mock_socket_conn, self.temp_dir, file_name, chunk, True,
|
||||
upload_id="first-chunk-retry",
|
||||
)
|
||||
handle_uploaded_file(
|
||||
self.mock_socket_conn, self.temp_dir, file_name,
|
||||
SimpleUploadedFile(file_name, b"complete-data"), True,
|
||||
upload_id="first-chunk-retry",
|
||||
)
|
||||
with open(os.path.join(self.temp_dir, file_name), "rb") as stream:
|
||||
self.assertEqual(stream.read(), b"complete-data")
|
||||
|
||||
def test_first_chunk_can_retry_after_failure_before_part_file_creation(self):
|
||||
chunk = SimpleUploadedFile("start-retry.iso", b"complete-data")
|
||||
with patch("storages.upload._UploadTarget.write_chunk", side_effect=OSError("Cannot open part")):
|
||||
with self.assertRaises(OSError):
|
||||
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, chunk.name, chunk, True, upload_id="start-retry")
|
||||
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, chunk.name, chunk, True, upload_id="start-retry")
|
||||
with open(os.path.join(self.temp_dir, chunk.name), "rb") as stream:
|
||||
self.assertEqual(stream.read(), b"complete-data")
|
||||
|
||||
|
||||
class StorageUploadViewTests(TestCase):
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_superuser(username="storage_admin", password="password", email="storage_admin@example.com")
|
||||
self.client.login(request=RequestFactory().get("/"), username="storage_admin", password="password")
|
||||
self.compute = Compute.objects.create(name="LocalCompute", hostname="localhost", type=1, login="u", password="p")
|
||||
|
||||
@patch("storages.views.wvmStorage")
|
||||
def test_iso_upload_rejects_other_extensions(self, mock_wvm):
|
||||
mock_conn = mock_wvm.return_value
|
||||
mock_conn.conn = CONN_SOCKET
|
||||
mock_conn.get_target_path.return_value = "/tmp"
|
||||
url = reverse("storage", args=[self.compute.id, "default"])
|
||||
response = self.client.post(url, {
|
||||
"iso_upload": "1", "file": SimpleUploadedFile("notes.txt", b"notes"),
|
||||
"file_name": "notes.txt", "chunk_index": "0", "total_chunks": "1",
|
||||
"upload_id": "invalid-extension-test",
|
||||
})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("Only .iso files", response.json()["error"])
|
||||
|
||||
@patch("storages.views.handle_uploaded_file")
|
||||
@patch("storages.views.wvmStorage")
|
||||
def test_tcp_upload_passes_declared_size(self, mock_wvm, mock_upload):
|
||||
mock_conn = mock_wvm.return_value
|
||||
mock_conn.conn = CONN_TCP
|
||||
mock_conn.get_target_path.return_value = "/var/lib/libvirt/images"
|
||||
url = reverse("storage", args=[self.compute.id, "default"])
|
||||
payload = {
|
||||
"iso_upload": "1", "file_name": "test.iso", "chunk_index": "0",
|
||||
"total_chunks": "1", "upload_id": "tcp-view-test",
|
||||
}
|
||||
response = self.client.post(url, {
|
||||
**payload, "file": SimpleUploadedFile("test.iso", b"iso"),
|
||||
})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
mock_upload.assert_not_called()
|
||||
|
||||
response = self.client.post(url, {
|
||||
**payload, "file": SimpleUploadedFile("test.iso", b"iso"),
|
||||
"file_size": "3",
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(mock_upload.call_args.kwargs["file_size"], 3)
|
||||
|
||||
@patch("storages.views.wvmStorage")
|
||||
def test_iso_upload_missing_chunk(self, mock_wvm):
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.get_status.return_value = 1
|
||||
mock_conn.get_target_path.return_value = "/tmp"
|
||||
mock_conn.get_type.return_value = "dir"
|
||||
mock_conn.get_autostart.return_value = 1
|
||||
mock_conn.get_size.return_value = [1000, 500]
|
||||
mock_conn.update_volumes.return_value = []
|
||||
mock_wvm.return_value = mock_conn
|
||||
|
||||
url = reverse("storage", args=[self.compute.id, "default"])
|
||||
res = self.client.post(url, {"iso_upload": "1", "file_name": "test.iso", "upload_id": "sess-view-1"})
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertIn("No file chunk was submitted", res.json().get("error", ""))
|
||||
|
||||
@patch("storages.views.wvmStorage")
|
||||
def test_iso_upload_invalid_filename(self, mock_wvm):
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.get_status.return_value = 1
|
||||
mock_conn.get_target_path.return_value = "/tmp"
|
||||
mock_conn.get_type.return_value = "dir"
|
||||
mock_conn.get_autostart.return_value = 1
|
||||
mock_conn.get_size.return_value = [1000, 500]
|
||||
mock_conn.update_volumes.return_value = []
|
||||
mock_wvm.return_value = mock_conn
|
||||
|
||||
url = reverse("storage", args=[self.compute.id, "default"])
|
||||
chunk = SimpleUploadedFile("test.iso", b"chunkdata")
|
||||
res = self.client.post(url, {
|
||||
"iso_upload": "1",
|
||||
"file": chunk,
|
||||
"file_name": "..\x00..",
|
||||
"chunk_index": "0",
|
||||
"total_chunks": "1",
|
||||
"upload_id": "sess-view-2",
|
||||
})
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertIn("Invalid file name", res.json().get("error", ""))
|
||||
|
||||
@patch("storages.views.wvmStorage")
|
||||
def test_iso_upload_missing_upload_id(self, mock_wvm):
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.get_status.return_value = 1
|
||||
mock_conn.get_target_path.return_value = "/tmp"
|
||||
mock_conn.get_type.return_value = "dir"
|
||||
mock_conn.get_autostart.return_value = 1
|
||||
mock_conn.get_size.return_value = [1000, 500]
|
||||
mock_conn.update_volumes.return_value = []
|
||||
mock_wvm.return_value = mock_conn
|
||||
|
||||
url = reverse("storage", args=[self.compute.id, "default"])
|
||||
chunk = SimpleUploadedFile("test.iso", b"chunkdata")
|
||||
res = self.client.post(url, {
|
||||
"iso_upload": "1",
|
||||
"file": chunk,
|
||||
"file_name": "test.iso",
|
||||
"chunk_index": "0",
|
||||
"total_chunks": "1",
|
||||
})
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertIn("Missing upload_id", res.json().get("error", ""))
|
||||
|
||||
@patch("storages.views.wvmStorage")
|
||||
def test_iso_upload_final_chunk_retry_idempotency(self, mock_wvm):
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.conn = CONN_SOCKET
|
||||
mock_conn.get_status.return_value = 1
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, temp_dir, ignore_errors=True)
|
||||
mock_conn.get_target_path.return_value = temp_dir
|
||||
mock_conn.get_type.return_value = "dir"
|
||||
mock_conn.get_autostart.return_value = 1
|
||||
mock_conn.get_size.return_value = [1000, 500]
|
||||
mock_conn.get_volumes.return_value = []
|
||||
mock_conn.update_volumes.return_value = []
|
||||
mock_wvm.return_value = mock_conn
|
||||
|
||||
url = reverse("storage", args=[self.compute.id, "default"])
|
||||
upload_id = "sess-view-retry-123"
|
||||
file_name = "test_retry.iso"
|
||||
chunk = SimpleUploadedFile(file_name, b"test payload")
|
||||
|
||||
# 1. First upload (single chunk, total_chunks=1)
|
||||
res1 = self.client.post(url, {
|
||||
"iso_upload": "1",
|
||||
"file": chunk,
|
||||
"file_name": file_name,
|
||||
"chunk_index": "0",
|
||||
"total_chunks": "1",
|
||||
"upload_id": upload_id,
|
||||
})
|
||||
self.assertEqual(res1.status_code, 200)
|
||||
self.assertTrue(res1.json().get("success"))
|
||||
|
||||
# Volume now appears in libvirt pool
|
||||
mock_conn.get_volumes.return_value = [file_name]
|
||||
|
||||
# 2. Retrying final chunk with same upload_id returns 200 idempotent success
|
||||
chunk_retry = SimpleUploadedFile(file_name, b"test payload")
|
||||
res2 = self.client.post(url, {
|
||||
"iso_upload": "1",
|
||||
"file": chunk_retry,
|
||||
"file_name": file_name,
|
||||
"chunk_index": "0",
|
||||
"total_chunks": "1",
|
||||
"upload_id": upload_id,
|
||||
})
|
||||
self.assertEqual(res2.status_code, 200)
|
||||
self.assertTrue(res2.json().get("success"))
|
||||
mock_conn.get_storages.assert_not_called()
|
||||
mock_conn.update_volumes.assert_not_called()
|
||||
mock_conn.refresh.assert_not_called()
|
||||
self.assertTrue(res2.json().get("reload"))
|
||||
|
||||
# 3. Upload with different upload_id returns 400 error
|
||||
chunk_attacker = SimpleUploadedFile(file_name, b"evil payload")
|
||||
res3 = self.client.post(url, {
|
||||
"iso_upload": "1",
|
||||
"file": chunk_attacker,
|
||||
"file_name": file_name,
|
||||
"chunk_index": "0",
|
||||
"total_chunks": "1",
|
||||
"upload_id": "sess-attacker-999",
|
||||
})
|
||||
self.assertEqual(res3.status_code, 400)
|
||||
self.assertIn("ISO image already exists", res3.json().get("error", ""))
|
||||
|
||||
# 4. Upload with same upload_id but by a different superuser cannot bypass/claim
|
||||
User.objects.create_superuser(username="other_admin", password="password", email="other@example.com")
|
||||
self.client.login(request=RequestFactory().get("/"), username="other_admin", password="password")
|
||||
res4 = self.client.post(url, {
|
||||
"iso_upload": "1",
|
||||
"file": chunk_retry,
|
||||
"file_name": file_name,
|
||||
"chunk_index": "0",
|
||||
"total_chunks": "1",
|
||||
"upload_id": upload_id,
|
||||
})
|
||||
self.assertEqual(res4.status_code, 400)
|
||||
self.assertIn("ISO image already exists", res4.json().get("error", ""))
|
||||
|
||||
|
||||
class GetVolumesViewTests(TestCase):
|
||||
"""get_volumes must not answer 200 with an empty list when the pool cannot
|
||||
be read: the caller could not tell that apart from a pool with no volumes."""
|
||||
|
|
|
|||
|
|
@ -1,25 +1,28 @@
|
|||
"""Chunked ISO upload operations shared by the storage view."""
|
||||
"""A chunked ISO upload: a job its chunk requests run (the chunk protocol)."""
|
||||
|
||||
import atexit
|
||||
import fcntl
|
||||
import json
|
||||
import logging
|
||||
import errno
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
import paramiko
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from libvirt import VIR_ERR_NO_STORAGE_VOL, libvirtError
|
||||
|
||||
from computes.models import Compute
|
||||
from operations import claims, jobs
|
||||
from operations.models import Claim, Operation
|
||||
from vrtManager.connection import CONN_SSH, CONN_SOCKET, CONN_TCP, CONN_TLS
|
||||
from vrtManager.storage import wvmStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
LOCK_TIMEOUT_SECONDS = 15
|
||||
IDLE = "The upload was idle for over an hour; its partial file was deleted"
|
||||
IDLE_SECONDS = 3600
|
||||
SSH_CONNECT_TIMEOUT_SECONDS = 10
|
||||
SSH_IO_TIMEOUT_SECONDS = 30
|
||||
SSH_IDLE_SECONDS = 60
|
||||
|
|
@ -122,17 +125,13 @@ def _atomic_finalize_local(target_temp, target_final):
|
|||
# 1. Primary: POSIX os.link creates a hard link atomically and fails with FileExistsError if target_final exists.
|
||||
try:
|
||||
os.link(target_temp, target_final)
|
||||
_fsync_dir(parent_dir)
|
||||
try:
|
||||
os.unlink(target_temp)
|
||||
_fsync_dir(parent_dir)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
except FileExistsError:
|
||||
raise FileExistsError(_("Destination file already exists"))
|
||||
# an earlier attempt linked the part file and died before unlinking it
|
||||
if not os.path.samestat(os.lstat(target_temp), os.lstat(target_final)):
|
||||
raise FileExistsError(_("Destination file already exists")) from None
|
||||
except OSError as link_err:
|
||||
# 2. Secondary: Linux kernel renameat2 with RENAME_NOREPLACE (flag 1)
|
||||
ret, err = -1, 0
|
||||
try:
|
||||
import ctypes
|
||||
libc = ctypes.CDLL("libc.so.6", use_errno=True)
|
||||
|
|
@ -143,17 +142,22 @@ def _atomic_finalize_local(target_temp, target_final):
|
|||
target_final.encode("utf-8"),
|
||||
ctypes.c_uint(1),
|
||||
)
|
||||
if ret == 0:
|
||||
_fsync_dir(parent_dir)
|
||||
return
|
||||
err = ctypes.get_errno()
|
||||
if err == 17: # EEXIST
|
||||
raise FileExistsError(_("Destination file already exists"))
|
||||
except (AttributeError, OSError):
|
||||
except (AttributeError, OSError): # no libc or no renameat2
|
||||
pass
|
||||
|
||||
if ret == 0:
|
||||
_fsync_dir(parent_dir)
|
||||
return
|
||||
if err == errno.EEXIST:
|
||||
raise FileExistsError(_("Destination file already exists"))
|
||||
# If atomic no-replace cannot be guaranteed, fail safely without destroying target_temp
|
||||
raise OSError(_("Filesystem does not support atomic no-replace finalization: {}").format(link_err))
|
||||
_fsync_dir(parent_dir)
|
||||
try:
|
||||
os.unlink(target_temp)
|
||||
_fsync_dir(parent_dir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _atomic_finalize_ssh(sftp, target_temp, target_final):
|
||||
|
|
@ -163,36 +167,17 @@ def _atomic_finalize_ssh(sftp, target_temp, target_final):
|
|||
"""
|
||||
try:
|
||||
sftp.rename(target_temp, target_final)
|
||||
except OSError as e:
|
||||
if "exist" in str(e).lower() or "already" in str(e).lower():
|
||||
raise FileExistsError(_("Destination file already exists")) from e
|
||||
raise
|
||||
|
||||
|
||||
_STALE_UPLOAD_PATTERN = re.compile(r"^.+\.wvc_upload_[a-zA-Z0-9_\-]{8,64}\.(part|meta|meta\.tmp)$")
|
||||
|
||||
|
||||
def _sftp_replace_file(sftp, src, dst, ssh=None):
|
||||
"""Replace metadata atomically, leaving the old copy intact on failure."""
|
||||
posix_rename = getattr(sftp, "posix_rename", None)
|
||||
if callable(posix_rename):
|
||||
except OSError as err:
|
||||
# OpenSSH answers a taken destination with a generic "Failure"
|
||||
try:
|
||||
posix_rename(src, dst)
|
||||
return
|
||||
except (IOError, OSError) as e:
|
||||
err_str = str(e).lower()
|
||||
if not ("unsupported" in err_str or getattr(e, "code", None) == 8 or isinstance(e, NotImplementedError)):
|
||||
raise
|
||||
if ssh is not None:
|
||||
import shlex
|
||||
cmd = f"python3 -c 'import os, sys; os.replace(sys.argv[1], sys.argv[2])' {shlex.quote(src)} {shlex.quote(dst)}"
|
||||
sftp.lstat(target_final)
|
||||
except OSError:
|
||||
raise err from None
|
||||
try:
|
||||
stdin, stdout, stderr = ssh.exec_command(cmd, timeout=SSH_IO_TIMEOUT_SECONDS)
|
||||
if stdout.channel.recv_exit_status() == 0:
|
||||
return
|
||||
except Exception as exc:
|
||||
raise OSError(_("Remote server cannot safely replace upload metadata")) from exc
|
||||
raise OSError(_("Remote server cannot safely replace upload metadata"))
|
||||
sftp.lstat(target_temp)
|
||||
except FileNotFoundError:
|
||||
return # the rename was done; its answer was lost
|
||||
raise FileExistsError(_("Destination file already exists")) from err
|
||||
|
||||
|
||||
def _write_pipelined(stream, chunk, offset):
|
||||
|
|
@ -215,209 +200,21 @@ def _write_pipelined(stream, chunk, offset):
|
|||
return offset
|
||||
|
||||
|
||||
def _cleanup_stale_local_uploads(base_dir, max_age_seconds=86400):
|
||||
try:
|
||||
now = time.time()
|
||||
for entry in os.scandir(base_dir):
|
||||
if entry.is_file() and _STALE_UPLOAD_PATTERN.match(entry.name):
|
||||
try:
|
||||
mtime = entry.stat().st_mtime
|
||||
if now - mtime > max_age_seconds:
|
||||
if entry.name.endswith(".meta"):
|
||||
try:
|
||||
with open(entry.path, "r", encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
if not meta.get("upload_id") and not meta.get("expected_chunk"):
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
os.unlink(entry.path)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _cleanup_stale_sftp_uploads(sftp, remote_base, max_age_seconds=86400):
|
||||
try:
|
||||
now = time.time()
|
||||
for attr in sftp.listdir_attr(remote_base):
|
||||
name = attr.filename
|
||||
if _STALE_UPLOAD_PATTERN.match(name):
|
||||
try:
|
||||
if attr.st_mtime and (now - attr.st_mtime > max_age_seconds):
|
||||
target_file = posixpath.join(remote_base, name)
|
||||
sftp.remove(target_file)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _get_upload_lock_directory():
|
||||
candidates = [
|
||||
"/srv/webvirtcloud/data/locks",
|
||||
os.path.join(tempfile.gettempdir(), f"webvirtcloud_upload_locks_{os.geteuid()}"),
|
||||
]
|
||||
for candidate in candidates:
|
||||
try:
|
||||
if os.path.islink(candidate):
|
||||
continue
|
||||
if not os.path.exists(candidate):
|
||||
os.makedirs(candidate, mode=0o700, exist_ok=True)
|
||||
if not os.path.isdir(candidate) or os.path.islink(candidate):
|
||||
continue
|
||||
stat_info = os.stat(candidate)
|
||||
if stat_info.st_uid != os.geteuid():
|
||||
continue
|
||||
if (stat.S_IMODE(stat_info.st_mode) & 0o077) != 0:
|
||||
try:
|
||||
os.chmod(candidate, 0o700)
|
||||
except OSError:
|
||||
continue
|
||||
stat_info = os.stat(candidate)
|
||||
if (stat.S_IMODE(stat_info.st_mode) & 0o077) != 0:
|
||||
continue
|
||||
return candidate
|
||||
except (OSError, PermissionError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _cleanup_stale_lock_dir(lock_dir, max_age_seconds=86400):
|
||||
if not lock_dir or not os.path.exists(lock_dir):
|
||||
return
|
||||
try:
|
||||
now = time.time()
|
||||
for entry in os.scandir(lock_dir):
|
||||
if entry.is_file():
|
||||
if entry.name.startswith("upload_") and (entry.name.endswith(".done") or entry.name.endswith(".done.tmp")):
|
||||
try:
|
||||
if now - entry.stat().st_mtime > max_age_seconds:
|
||||
os.unlink(entry.path)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _mark_session_completed(lock_dir, upload_id, file_name, path=None, user_id=None, compute_id=None, pool=None):
|
||||
if not lock_dir or not upload_id or not file_name:
|
||||
return
|
||||
marker_path = os.path.join(lock_dir, f"upload_{upload_id}.done")
|
||||
if os.path.islink(marker_path):
|
||||
try:
|
||||
os.unlink(marker_path)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
tmp_marker = f"{marker_path}.tmp"
|
||||
marker_data = {
|
||||
"upload_id": upload_id,
|
||||
"file_name": file_name,
|
||||
"path": posixpath.normpath(path) if path else None,
|
||||
"user_id": user_id,
|
||||
"compute_id": compute_id,
|
||||
"pool": pool,
|
||||
"completed_at": time.time(),
|
||||
}
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
||||
fd = os.open(tmp_marker, flags, 0o600)
|
||||
with open(fd, "w", encoding="utf-8", closefd=True) as f:
|
||||
json.dump(marker_data, f)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_marker, marker_path)
|
||||
_fsync_dir(lock_dir)
|
||||
except Exception as e:
|
||||
logger.error("Failed to write upload session completion marker %s: %s", marker_path, e)
|
||||
raise
|
||||
|
||||
|
||||
def _is_session_completed(upload_id, file_name, path=None, user_id=None, compute_id=None, pool=None, lock_dir=None, check_local_recovery=True):
|
||||
if not upload_id or not file_name:
|
||||
return False
|
||||
if not lock_dir:
|
||||
lock_dir = _get_upload_lock_directory()
|
||||
if not lock_dir:
|
||||
return False
|
||||
marker_path = os.path.join(lock_dir, f"upload_{upload_id}.done")
|
||||
if os.path.exists(marker_path) and not os.path.islink(marker_path):
|
||||
try:
|
||||
with open(marker_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
marker_valid = True
|
||||
if data.get("upload_id") != upload_id or data.get("file_name") != file_name:
|
||||
marker_valid = False
|
||||
if path and data.get("path"):
|
||||
if posixpath.normpath(path) != posixpath.normpath(data["path"]):
|
||||
marker_valid = False
|
||||
if user_id is not None and data.get("user_id") is not None:
|
||||
if str(data["user_id"]) != str(user_id):
|
||||
marker_valid = False
|
||||
if compute_id is not None and data.get("compute_id") is not None:
|
||||
if str(data["compute_id"]) != str(compute_id):
|
||||
marker_valid = False
|
||||
if pool is not None and data.get("pool") is not None:
|
||||
if str(data["pool"]) != str(pool):
|
||||
marker_valid = False
|
||||
if time.time() - data.get("completed_at", 0) > 86400:
|
||||
try:
|
||||
os.unlink(marker_path)
|
||||
_fsync_dir(lock_dir)
|
||||
except OSError:
|
||||
pass
|
||||
marker_valid = False
|
||||
if marker_valid:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Crash-recovery: check if metadata file exists in path with complete chunks and matching size
|
||||
if path and check_local_recovery:
|
||||
meta_suffix = f".wvc_upload_{upload_id}.meta"
|
||||
meta_path = os.path.join(path, f"{file_name}{meta_suffix}")
|
||||
final_path = os.path.join(path, file_name)
|
||||
if os.path.exists(meta_path) and os.path.exists(final_path) and not os.path.islink(meta_path):
|
||||
try:
|
||||
with open(meta_path, "r", encoding="utf-8") as mf:
|
||||
meta = json.load(mf)
|
||||
if (
|
||||
meta.get("file_name") == file_name
|
||||
and meta.get("expected_chunk") == meta.get("total_chunks")
|
||||
and os.path.getsize(final_path) == meta.get("current_size")
|
||||
):
|
||||
try:
|
||||
_mark_session_completed(
|
||||
lock_dir, upload_id, file_name, path,
|
||||
user_id=user_id, compute_id=compute_id, pool=pool,
|
||||
)
|
||||
os.unlink(meta_path)
|
||||
_fsync_dir(path)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class _UploadTarget:
|
||||
"""Filesystem differences for one upload; chunk ordering lives in one place."""
|
||||
"""The part file of an upload to a local (socket) or SSH pool, renamed to
|
||||
the image when the last chunk is written."""
|
||||
|
||||
def __init__(self, conn, path, file_name, upload_id):
|
||||
self.conn = conn
|
||||
self.remote = conn.conn == CONN_SSH
|
||||
if not self.remote and conn.conn != CONN_SOCKET:
|
||||
raise ValueError(_("Unsupported connection type for file upload."))
|
||||
self.base = posixpath.normpath(path) if self.remote else os.path.abspath(path)
|
||||
join = posixpath.join if self.remote else os.path.join
|
||||
normalize = posixpath.normpath if self.remote else os.path.abspath
|
||||
self.base = normalize(path)
|
||||
self.final = normalize(join(self.base, file_name))
|
||||
self.part = normalize(join(self.base, f"{file_name}.wvc_upload_{upload_id}.part"))
|
||||
self.meta = normalize(join(self.base, f"{file_name}.wvc_upload_{upload_id}.meta"))
|
||||
for target in (self.final, self.part, self.meta):
|
||||
for target in (self.final, self.part):
|
||||
if self.remote:
|
||||
contained = target.startswith(self.base.rstrip("/") + "/")
|
||||
else:
|
||||
|
|
@ -505,41 +302,13 @@ class _UploadTarget:
|
|||
raise PermissionError(_("Security issues with file uploading: symlink detected"))
|
||||
return info
|
||||
|
||||
def read_meta(self):
|
||||
if self.info(self.meta) is None:
|
||||
return None
|
||||
try:
|
||||
if self.remote:
|
||||
with self.sftp.open(self.meta, "r") as stream:
|
||||
raw = stream.read()
|
||||
else:
|
||||
with open(self.meta, "r", encoding="utf-8") as stream:
|
||||
raw = stream.read()
|
||||
return json.loads(raw)
|
||||
except Exception as exc:
|
||||
raise ValueError(_("Corrupted upload session metadata")) from exc
|
||||
def present(self):
|
||||
return self.info(self.part) is not None
|
||||
|
||||
def write_meta(self, data):
|
||||
temporary = self.meta + ".tmp"
|
||||
if self.remote:
|
||||
if self.info(temporary) is not None:
|
||||
self.sftp.remove(temporary)
|
||||
with self.sftp.open(temporary, "w") as stream:
|
||||
stream.write(json.dumps(data))
|
||||
if callable(getattr(stream, "flush", None)):
|
||||
stream.flush()
|
||||
_sftp_replace_file(self.sftp, temporary, self.meta, ssh=self.ssh)
|
||||
else:
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
||||
fd = os.open(temporary, flags, 0o600)
|
||||
with open(fd, "w", encoding="utf-8", closefd=True) as stream:
|
||||
json.dump(data, stream)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, self.meta)
|
||||
_fsync_dir(self.base)
|
||||
def done(self):
|
||||
return self.info(self.final) is not None
|
||||
|
||||
def write_chunk(self, chunk, offset, new):
|
||||
def write(self, chunk, offset, new):
|
||||
if self.remote:
|
||||
with self.sftp.open(self.part, "wb" if new else "r+b") as stream:
|
||||
if not new:
|
||||
|
|
@ -560,6 +329,8 @@ class _UploadTarget:
|
|||
offset += len(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
if new: # the part file's name survives a host crash once its offset is committed
|
||||
_fsync_dir(self.base)
|
||||
return offset
|
||||
|
||||
def finalize(self):
|
||||
|
|
@ -568,268 +339,223 @@ class _UploadTarget:
|
|||
else:
|
||||
_atomic_finalize_local(self.part, self.final)
|
||||
|
||||
def remove_meta(self):
|
||||
try:
|
||||
if self.remote:
|
||||
self.sftp.remove(self.meta)
|
||||
else:
|
||||
os.unlink(self.meta)
|
||||
_fsync_dir(self.base)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
def cleanup(self, lock_dir):
|
||||
if self.remote:
|
||||
_cleanup_stale_sftp_uploads(self.sftp, self.base)
|
||||
else:
|
||||
_cleanup_stale_local_uploads(self.base)
|
||||
_cleanup_stale_lock_dir(lock_dir)
|
||||
|
||||
|
||||
def _matches_session(meta, file_name, total_chunks, user_id, compute_id, pool):
|
||||
if meta.get("file_name") != file_name:
|
||||
raise ValueError(_("File name does not match upload session"))
|
||||
if meta.get("total_chunks") != total_chunks:
|
||||
raise ValueError(_("Inconsistent total_chunks for session"))
|
||||
for key, expected in (("user_id", user_id), ("compute_id", compute_id), ("pool", pool)):
|
||||
if expected is not None and meta.get(key) is not None and str(meta[key]) != str(expected):
|
||||
raise PermissionError(_("Upload session belongs to another request context"))
|
||||
|
||||
|
||||
def _acquire_upload_lock(lock_fd):
|
||||
deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
def remove(self):
|
||||
if not self.present():
|
||||
return
|
||||
except BlockingIOError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(_("Timed out waiting for upload session lock"))
|
||||
time.sleep(0.05)
|
||||
if self.remote:
|
||||
self.sftp.remove(self.part)
|
||||
else:
|
||||
os.unlink(self.part)
|
||||
_fsync_dir(self.base)
|
||||
|
||||
|
||||
def _write_libvirt_meta(meta_path, data):
|
||||
temporary = meta_path + ".tmp"
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
||||
fd = os.open(temporary, flags, 0o600)
|
||||
with open(fd, "w", encoding="utf-8", closefd=True) as stream:
|
||||
json.dump(data, stream)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, meta_path)
|
||||
_fsync_dir(os.path.dirname(meta_path))
|
||||
class _VolumeTarget:
|
||||
"""The volume of an upload to a TCP/TLS pool, written through libvirt
|
||||
streams; it has the image's name from the first chunk."""
|
||||
|
||||
def __init__(self, conn, name, size):
|
||||
self.conn = conn
|
||||
self.name = name
|
||||
self.size = size
|
||||
|
||||
def _handle_libvirt_upload(
|
||||
conn, path, file_name, file_chunk, is_last_chunk, chunk_index,
|
||||
total_chunks, upload_id, user_id, compute_id, pool, file_size, lock_dir,
|
||||
):
|
||||
"""Upload to a remote pool through the existing libvirt connection."""
|
||||
if not isinstance(file_size, int) or file_size <= 0:
|
||||
raise ValueError(_("A positive file_size is required for TCP/TLS uploads"))
|
||||
if file_chunk.size <= 0:
|
||||
raise ValueError(_("Empty upload chunk"))
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
meta_path = os.path.join(lock_dir, f"upload_{upload_id}.libvirt.json")
|
||||
if os.path.islink(meta_path):
|
||||
raise PermissionError(_("Invalid upload session metadata"))
|
||||
if os.path.exists(meta_path):
|
||||
def __exit__(self, *_):
|
||||
pass
|
||||
|
||||
def present(self):
|
||||
try:
|
||||
with open(meta_path, "r", encoding="utf-8") as stream:
|
||||
meta = json.load(stream)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ValueError(_("Corrupted upload session metadata")) from exc
|
||||
_matches_session(meta, file_name, total_chunks, user_id, compute_id, pool)
|
||||
if meta.get("path") != path or meta.get("file_size") != file_size:
|
||||
raise ValueError(_("Upload session parameters changed"))
|
||||
else:
|
||||
if chunk_index != 0:
|
||||
raise ValueError(_("Upload session not found or expired"))
|
||||
meta = {
|
||||
"file_name": file_name, "total_chunks": total_chunks,
|
||||
"expected_chunk": 0, "current_size": 0, "file_size": file_size,
|
||||
"path": path, "user_id": user_id, "compute_id": compute_id,
|
||||
"pool": pool, "created_at": time.time(),
|
||||
}
|
||||
self.conn.get_volume(self.name)
|
||||
except libvirtError as err:
|
||||
if err.get_error_code() == VIR_ERR_NO_STORAGE_VOL:
|
||||
return False
|
||||
raise
|
||||
return True
|
||||
|
||||
expected = meta["expected_chunk"]
|
||||
if chunk_index < expected:
|
||||
if is_last_chunk and expected == total_chunks:
|
||||
_mark_session_completed(
|
||||
lock_dir, upload_id, file_name, path,
|
||||
user_id=user_id, compute_id=compute_id, pool=pool,
|
||||
)
|
||||
os.unlink(meta_path)
|
||||
_fsync_dir(lock_dir)
|
||||
return
|
||||
if chunk_index != expected:
|
||||
raise ValueError(
|
||||
_("Invalid chunk sequence. Expected chunk %(exp)d, got %(got)d")
|
||||
% {"exp": expected, "got": chunk_index}
|
||||
)
|
||||
done = present
|
||||
|
||||
offset = meta["current_size"]
|
||||
if offset + file_chunk.size > file_size:
|
||||
raise ValueError(_("Upload exceeds declared file size"))
|
||||
if is_last_chunk and offset + file_chunk.size != file_size:
|
||||
raise ValueError(_("Final chunk does not match declared file size"))
|
||||
def write(self, chunk, offset, new):
|
||||
if new:
|
||||
xml = (
|
||||
"<volume><name>{}</name><capacity unit='bytes'>{}</capacity>"
|
||||
"<target><format type='raw'/></target></volume>"
|
||||
).format(escape(self.name), self.size)
|
||||
self.conn.pool.createXML(xml, 0)
|
||||
volume = self.conn.get_volume(self.name)
|
||||
stream = self.conn.wvm.newStream(0)
|
||||
try:
|
||||
volume.upload(stream, offset, chunk.size, 0)
|
||||
written = 0
|
||||
for data in chunk.chunks():
|
||||
while data:
|
||||
sent = stream.send(data)
|
||||
if sent <= 0:
|
||||
raise OSError(_("Libvirt stream stopped during upload"))
|
||||
written += sent
|
||||
data = data[sent:]
|
||||
if written != chunk.size:
|
||||
raise OSError(_("Upload chunk size changed during transfer"))
|
||||
stream.finish()
|
||||
except Exception:
|
||||
stream.abort()
|
||||
raise
|
||||
return offset + written
|
||||
|
||||
# A volume is visible as soon as libvirt creates it. Keep the session until
|
||||
# the final chunk finishes so retries can resume safely at a known offset.
|
||||
volumes = conn.get_volumes()
|
||||
if meta["expected_chunk"] == 0 and not os.path.exists(meta_path):
|
||||
if file_name in volumes:
|
||||
raise FileExistsError(_("Destination file already exists"))
|
||||
xml = (
|
||||
"<volume><name>{}</name><capacity unit='bytes'>{}</capacity>"
|
||||
"<target><format type='raw'/></target></volume>"
|
||||
).format(escape(file_name), file_size)
|
||||
conn.pool.createXML(xml, 0)
|
||||
_write_libvirt_meta(meta_path, meta)
|
||||
elif file_name not in volumes:
|
||||
raise ValueError(_("Upload volume is missing"))
|
||||
def finalize(self):
|
||||
pass
|
||||
|
||||
volume = conn.get_volume(file_name)
|
||||
stream = conn.wvm.newStream(0)
|
||||
def remove(self):
|
||||
if self.present():
|
||||
self.conn.get_volume(self.name).delete(0)
|
||||
|
||||
|
||||
def _target(conn, op):
|
||||
if conn.conn in (CONN_TCP, CONN_TLS):
|
||||
return _VolumeTarget(conn, op.params["name"], op.params["size"])
|
||||
return _UploadTarget(conn, conn.get_target_path(), op.params["name"], op.pk.hex)
|
||||
|
||||
|
||||
def _session_key(op):
|
||||
"""Held by a chunk request, and by the ending path, for the whole of its work."""
|
||||
return f"upload:{op.pk}"
|
||||
|
||||
|
||||
def _idle(op):
|
||||
return op.heartbeat_at < timezone.now() - timedelta(seconds=IDLE_SECONDS)
|
||||
|
||||
|
||||
def _commit(op, **fields):
|
||||
if not Operation.objects.filter(pk=op.pk, state__in=Operation.ACTIVE).update(**fields):
|
||||
raise ValueError(_("The upload has ended"))
|
||||
for name, value in fields.items():
|
||||
setattr(op, name, value)
|
||||
|
||||
|
||||
def _complete(target, op):
|
||||
"""Rename the part file to the image, unless a retry finds it done, and
|
||||
succeed; a name taken meanwhile, or an image gone, fails the upload."""
|
||||
try:
|
||||
volume.upload(stream, offset, file_chunk.size, 0)
|
||||
written = 0
|
||||
for data in file_chunk.chunks():
|
||||
while data:
|
||||
sent = stream.send(data)
|
||||
if sent <= 0:
|
||||
raise OSError(_("Libvirt stream stopped during upload"))
|
||||
written += sent
|
||||
data = data[sent:]
|
||||
if written != file_chunk.size:
|
||||
raise OSError(_("Upload chunk size changed during transfer"))
|
||||
stream.finish()
|
||||
except Exception:
|
||||
stream.abort()
|
||||
if target.present():
|
||||
target.finalize()
|
||||
elif not target.done():
|
||||
jobs.finish(op, jobs.Outcome(Operation.FAILED, error="The uploaded image is missing"))
|
||||
raise ValueError(_("The uploaded image is missing"))
|
||||
except FileExistsError:
|
||||
target.remove()
|
||||
jobs.finish(op, jobs.Outcome(Operation.FAILED, error="An image of this name appeared before the upload "
|
||||
"finished; the upload was deleted"))
|
||||
raise
|
||||
|
||||
meta["current_size"] = offset + written
|
||||
meta["expected_chunk"] = expected + 1
|
||||
meta["updated_at"] = time.time()
|
||||
_write_libvirt_meta(meta_path, meta)
|
||||
if is_last_chunk:
|
||||
_mark_session_completed(
|
||||
lock_dir, upload_id, file_name, path,
|
||||
user_id=user_id, compute_id=compute_id, pool=pool,
|
||||
)
|
||||
os.unlink(meta_path)
|
||||
_fsync_dir(lock_dir)
|
||||
jobs.finish(op, jobs.Outcome(Operation.SUCCEEDED))
|
||||
|
||||
|
||||
def handle_uploaded_file(
|
||||
conn, path, file_name, file_chunk, is_last_chunk, chunk_index=0,
|
||||
total_chunks=1, upload_id=None, user_id=None, compute_id=None, pool=None,
|
||||
file_size=None,
|
||||
):
|
||||
clean_name = os.path.basename(file_name).strip()
|
||||
if not clean_name or clean_name in (".", "..") or "\x00" in clean_name:
|
||||
raise ValueError(_("Invalid file name"))
|
||||
if len(clean_name) <= 4 or not clean_name.lower().endswith(".iso"):
|
||||
raise ValueError(_("Only .iso files can be uploaded"))
|
||||
if total_chunks < 1 or chunk_index < 0 or chunk_index >= total_chunks:
|
||||
raise ValueError(_("Invalid chunk index or total chunks."))
|
||||
if is_last_chunk != (chunk_index == total_chunks - 1):
|
||||
raise ValueError(_("Invalid final chunk flag"))
|
||||
if not upload_id or not isinstance(upload_id, str):
|
||||
raise ValueError(_("Missing required upload_id"))
|
||||
upload_id = upload_id.strip()
|
||||
if not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", upload_id):
|
||||
raise ValueError(_("Invalid upload_id format"))
|
||||
def _end(target, op, state, error=""):
|
||||
if op.intent.get("created"):
|
||||
target.remove()
|
||||
jobs.finish(op, jobs.Outcome(state, error=error))
|
||||
|
||||
libvirt_upload = conn.conn in (CONN_TCP, CONN_TLS)
|
||||
target = None if libvirt_upload else _UploadTarget(conn, path, clean_name, upload_id)
|
||||
lock_dir = _get_upload_lock_directory()
|
||||
if not lock_dir:
|
||||
raise OSError(_("Could not acquire secure upload lock directory"))
|
||||
lock_path = os.path.join(lock_dir, f"upload_session_{upload_id}.lock")
|
||||
flags = os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0)
|
||||
lock_fd = os.open(lock_path, flags, 0o600)
|
||||
|
||||
def _refuse(target, op, state, message, error=""):
|
||||
"""End the upload and refuse the chunk."""
|
||||
_end(target, op, state, error)
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def end(op, state, error=""):
|
||||
"""Delete what the upload made and end it, unless a chunk request holds
|
||||
it: that request sees the cancel, or the next expiry pass retries. A
|
||||
requested cancel ends it cancelled. An upload whose every chunk is in is
|
||||
completed instead: a cancel then comes too late."""
|
||||
try:
|
||||
_acquire_upload_lock(lock_fd)
|
||||
if libvirt_upload:
|
||||
if _is_session_completed(
|
||||
upload_id, clean_name, path, user_id=user_id,
|
||||
compute_id=compute_id, pool=pool, lock_dir=lock_dir,
|
||||
check_local_recovery=False,
|
||||
):
|
||||
if is_last_chunk:
|
||||
if clean_name not in conn.get_volumes():
|
||||
raise ValueError(_("Upload volume is missing"))
|
||||
return
|
||||
raise FileExistsError(_("Destination file already exists"))
|
||||
return _handle_libvirt_upload(
|
||||
conn, path, clean_name, file_chunk, is_last_chunk, chunk_index,
|
||||
total_chunks, upload_id, user_id, compute_id, pool, file_size, lock_dir,
|
||||
)
|
||||
with target:
|
||||
if chunk_index == 0:
|
||||
target.cleanup(lock_dir)
|
||||
final_info = target.info(target.final)
|
||||
if final_info is not None:
|
||||
if is_last_chunk and _is_session_completed(
|
||||
upload_id, clean_name, target.base, user_id=user_id,
|
||||
compute_id=compute_id, pool=pool, lock_dir=lock_dir,
|
||||
):
|
||||
return
|
||||
# A crash after rename but before the completion marker is recoverable.
|
||||
meta = target.read_meta() if is_last_chunk else None
|
||||
if meta is not None:
|
||||
_matches_session(meta, clean_name, total_chunks, user_id, compute_id, pool)
|
||||
if (meta.get("expected_chunk") == total_chunks
|
||||
and final_info.st_size == meta.get("current_size")):
|
||||
_mark_session_completed(lock_dir, upload_id, clean_name, target.base,
|
||||
user_id=user_id, compute_id=compute_id, pool=pool)
|
||||
target.remove_meta()
|
||||
return
|
||||
raise FileExistsError(_("Destination file already exists"))
|
||||
|
||||
meta = target.read_meta()
|
||||
if meta is None:
|
||||
if chunk_index != 0 or target.info(target.part) is not None:
|
||||
raise ValueError(_("Upload session not found or expired"))
|
||||
meta = {
|
||||
"file_name": clean_name, "total_chunks": total_chunks,
|
||||
"expected_chunk": 0, "current_size": 0,
|
||||
"created_at": time.time(), "user_id": user_id,
|
||||
"compute_id": compute_id, "pool": pool, "upload_id": upload_id,
|
||||
}
|
||||
# Record ownership before creating the part file, so an interrupted
|
||||
# first write can be retried without adopting an unrelated file.
|
||||
target.write_meta(meta)
|
||||
else:
|
||||
_matches_session(meta, clean_name, total_chunks, user_id, compute_id, pool)
|
||||
if meta["expected_chunk"] > 0 and target.info(target.part) is None:
|
||||
raise ValueError(_("Upload session not found or expired"))
|
||||
|
||||
expected = meta["expected_chunk"]
|
||||
if chunk_index < expected:
|
||||
if is_last_chunk and expected == total_chunks:
|
||||
target.finalize()
|
||||
_mark_session_completed(lock_dir, upload_id, clean_name, target.base,
|
||||
user_id=user_id, compute_id=compute_id, pool=pool)
|
||||
target.remove_meta()
|
||||
with claims.hold({_session_key(op): Claim.EXCLUSIVE}):
|
||||
op.refresh_from_db()
|
||||
if op.state not in Operation.ACTIVE or (error == IDLE and not _idle(op)):
|
||||
return # ended already, or expiry of an upload that is not idle
|
||||
if op.cancel_requested:
|
||||
state, error = Operation.CANCELLED, ""
|
||||
if not op.intent.get("created"):
|
||||
jobs.finish(op, jobs.Outcome(state, error=error))
|
||||
return
|
||||
if chunk_index != expected:
|
||||
raise ValueError(
|
||||
_("Invalid chunk sequence. Expected chunk %(exp)d, got %(got)d")
|
||||
% {"exp": expected, "got": chunk_index}
|
||||
)
|
||||
compute = Compute.objects.filter(pk=op.params["compute"]).first()
|
||||
if compute is None:
|
||||
jobs.finish(op, jobs.Outcome(Operation.FAILED, error="The compute was deleted"))
|
||||
return
|
||||
conn = wvmStorage(compute.hostname, compute.login, compute.password, compute.type, op.params["pool"])
|
||||
try:
|
||||
with _target(conn, op) as target:
|
||||
# its last request died before the rename
|
||||
if op.intent["chunk"] == op.params["chunks"]:
|
||||
try:
|
||||
_complete(target, op)
|
||||
except (FileExistsError, ValueError): # _complete failed the upload
|
||||
pass
|
||||
else:
|
||||
_end(target, op, state, error)
|
||||
finally:
|
||||
conn.close()
|
||||
except claims.Busy:
|
||||
pass
|
||||
|
||||
meta["current_size"] = target.write_chunk(
|
||||
file_chunk, meta["current_size"], new=target.info(target.part) is None
|
||||
)
|
||||
meta["expected_chunk"] = expected + 1
|
||||
meta["updated_at"] = time.time()
|
||||
target.write_meta(meta)
|
||||
if is_last_chunk:
|
||||
target.finalize()
|
||||
_mark_session_completed(lock_dir, upload_id, clean_name, target.base,
|
||||
user_id=user_id, compute_id=compute_id, pool=pool)
|
||||
target.remove_meta()
|
||||
finally:
|
||||
os.close(lock_fd)
|
||||
|
||||
def write_chunk(user, conn, compute, pool, upload_id, raw, chunk_index, chunk, request=None):
|
||||
"""Write chunk chunk_index of the upload upload_id (the job's submission
|
||||
key) and commit it on the job; the last chunk renames the image."""
|
||||
kind = jobs.get_kind("volume.upload")
|
||||
raw = {**raw, "compute": compute.pk, "pool": pool}
|
||||
if chunk_index == 0:
|
||||
op = jobs.submit(kind.name, user, raw, upload_id)
|
||||
if request is not None:
|
||||
jobs.remember_job(request, op)
|
||||
else:
|
||||
op = Operation.objects.filter(user=user, submission_key=upload_id).first()
|
||||
if op is None:
|
||||
raise ValueError(_("Upload session not found or expired"))
|
||||
if op.kind != kind.name or op.params != kind.normalize(raw):
|
||||
raise ValueError(_("Upload session parameters changed"))
|
||||
|
||||
with claims.hold({_session_key(op): Claim.EXCLUSIVE}):
|
||||
op.refresh_from_db()
|
||||
params = op.params
|
||||
last = chunk_index == params["chunks"] - 1
|
||||
if op.state == Operation.SUCCEEDED and last:
|
||||
return op # the answer to the last chunk was lost
|
||||
if op.state not in Operation.ACTIVE:
|
||||
raise ValueError(op.error or _("The upload has ended"))
|
||||
with _target(conn, op) as target:
|
||||
expected = op.intent["chunk"]
|
||||
# once every chunk is in, a cancel comes too late: a retry completes it below
|
||||
if op.cancel_requested and expected < params["chunks"]:
|
||||
_refuse(target, op, Operation.CANCELLED, _("The upload was cancelled"))
|
||||
if chunk_index > expected:
|
||||
raise ValueError(_("Invalid chunk sequence. Expected chunk %(exp)d, got %(got)d")
|
||||
% {"exp": expected, "got": chunk_index})
|
||||
if chunk_index < expected: # a retry of a committed chunk
|
||||
if last and expected == params["chunks"]:
|
||||
_complete(target, op)
|
||||
return op
|
||||
if _idle(op):
|
||||
_refuse(target, op, Operation.FAILED, IDLE, IDLE)
|
||||
offset = op.progress_done or 0
|
||||
if chunk.size <= 0:
|
||||
raise ValueError(_("Empty upload chunk"))
|
||||
if offset + chunk.size > params["size"]:
|
||||
raise ValueError(_("Upload exceeds declared file size"))
|
||||
if last and offset + chunk.size != params["size"]:
|
||||
raise ValueError(_("Final chunk does not match declared file size"))
|
||||
present = target.present() # refuses a symlinked part file
|
||||
# a first write that failed before creating it is retried as new
|
||||
new = not (op.intent["created"] and present)
|
||||
if new and offset:
|
||||
jobs.finish(op, jobs.Outcome(Operation.FAILED, error="The partial upload is missing"))
|
||||
raise ValueError(_("The partial upload is missing"))
|
||||
if not op.intent["created"]:
|
||||
_commit(op, intent={"chunk": expected, "created": True})
|
||||
written = target.write(chunk, offset, new)
|
||||
_commit(op, progress_done=written, progress_total=params["size"],
|
||||
intent={"chunk": expected + 1, "created": True}, heartbeat_at=timezone.now())
|
||||
if last: # a cancel during the write comes too late
|
||||
_complete(target, op)
|
||||
# a cancel that came during the write found this request holding the upload
|
||||
elif Operation.objects.filter(pk=op.pk, cancel_requested=True).exists():
|
||||
_refuse(target, op, Operation.CANCELLED, _("The upload was cancelled"))
|
||||
return op
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
import os
|
||||
import re
|
||||
|
||||
from admin.decorators import superuser_only
|
||||
from appsettings.settings import app_settings
|
||||
from computes.models import Compute
|
||||
|
|
@ -14,84 +11,46 @@ from libvirt import libvirtError
|
|||
from operations import claims, jobs
|
||||
from operations.utils import busy_message
|
||||
|
||||
from vrtManager.connection import CONN_TCP, CONN_TLS
|
||||
from vrtManager.util import OperationError
|
||||
from vrtManager.storage import wvmStorage, wvmStorages
|
||||
|
||||
from storages.forms import AddStgPool, CloneImage, CreateVolumeForm
|
||||
from storages.upload import handle_uploaded_file
|
||||
from storages.upload import write_chunk
|
||||
|
||||
|
||||
def _handle_iso_upload(request, conn, path, compute, pool):
|
||||
def _handle_iso_upload(request, conn, compute, pool):
|
||||
file_chunk = request.FILES.get("file")
|
||||
if not file_chunk:
|
||||
return JsonResponse({"error": _("No file chunk was submitted.")}, status=400)
|
||||
|
||||
file_name = request.POST.get("file_name", "")
|
||||
clean_file_name = os.path.basename(file_name).strip()
|
||||
if not clean_file_name or clean_file_name in (".", "..") or "\x00" in clean_file_name:
|
||||
return JsonResponse({"error": _("Invalid file name.")}, status=400)
|
||||
file_name = clean_file_name
|
||||
|
||||
try:
|
||||
chunk_index = int(request.POST.get("chunk_index", 0))
|
||||
total_chunks = int(request.POST.get("total_chunks", 1))
|
||||
except (ValueError, TypeError):
|
||||
return JsonResponse({"error": _("Invalid chunk parameters.")}, status=400)
|
||||
|
||||
if total_chunks < 1 or chunk_index < 0 or chunk_index >= total_chunks:
|
||||
if chunk_index < 0 or chunk_index >= total_chunks:
|
||||
return JsonResponse({"error": _("Invalid chunk index or total chunks.")}, status=400)
|
||||
|
||||
is_last_chunk = chunk_index == total_chunks - 1
|
||||
|
||||
file_size = None
|
||||
if conn.conn in (CONN_TCP, CONN_TLS):
|
||||
try:
|
||||
file_size = int(request.POST.get("file_size", ""))
|
||||
except (ValueError, TypeError):
|
||||
return JsonResponse({"error": _("Invalid file size.")}, status=400)
|
||||
if file_size <= 0:
|
||||
return JsonResponse({"error": _("Invalid file size.")}, status=400)
|
||||
|
||||
upload_id = request.POST.get("upload_id")
|
||||
if not upload_id or not isinstance(upload_id, str):
|
||||
return JsonResponse({"error": _("Missing upload_id.")}, status=400)
|
||||
upload_id = upload_id.strip()
|
||||
if not re.match(r"^[a-zA-Z0-9_\-]+$", upload_id) or len(upload_id) > 64:
|
||||
return JsonResponse({"error": _("Invalid upload_id format.")}, status=400)
|
||||
try:
|
||||
file_size = int(request.POST.get("file_size", ""))
|
||||
except (ValueError, TypeError):
|
||||
return JsonResponse({"error": _("Invalid file size.")}, status=400)
|
||||
|
||||
try:
|
||||
handle_uploaded_file(
|
||||
conn,
|
||||
path,
|
||||
file_name,
|
||||
file_chunk,
|
||||
is_last_chunk,
|
||||
chunk_index=chunk_index,
|
||||
total_chunks=total_chunks,
|
||||
upload_id=upload_id,
|
||||
user_id=request.user.id,
|
||||
compute_id=compute.id,
|
||||
pool=pool,
|
||||
file_size=file_size,
|
||||
)
|
||||
|
||||
if is_last_chunk:
|
||||
success_msg = _("ISO: %(file)s has been uploaded successfully.") % {"file": file_name}
|
||||
messages.success(request, success_msg)
|
||||
return JsonResponse({"success": True, "message": success_msg, "reload": True})
|
||||
else:
|
||||
return JsonResponse({"success": True, "message": "Chunk received."})
|
||||
op = write_chunk(request.user, conn, compute, pool, request.POST.get("upload_id", ""),
|
||||
{"name": request.POST.get("file_name", ""), "size": file_size, "chunks": total_chunks},
|
||||
chunk_index, file_chunk, request=request)
|
||||
except claims.Busy as busy:
|
||||
if busy.blocker is not None: # another job holds the pool or the name
|
||||
return JsonResponse({"error": busy_message(busy, request.user)}, status=400)
|
||||
return JsonResponse({"error": _("Upload session is busy. Please retry.")}, status=429)
|
||||
except FileExistsError:
|
||||
return JsonResponse({"error": _("ISO image already exists")}, status=400)
|
||||
except TimeoutError:
|
||||
return JsonResponse({"error": _("Upload session is busy. Please retry.")}, status=429)
|
||||
except ValueError as e:
|
||||
except (ValueError, jobs.SubmissionConflict, OperationError) as e:
|
||||
return JsonResponse({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
messages.error(request, error_msg)
|
||||
return JsonResponse({"error": error_msg}, status=500)
|
||||
return JsonResponse({"error": str(e)}, status=500)
|
||||
return JsonResponse({"success": True, "job": str(op.pk)})
|
||||
|
||||
|
||||
@superuser_only
|
||||
|
|
@ -191,7 +150,7 @@ def storage(request, compute_id, pool):
|
|||
|
||||
if request.method == "POST" and "iso_upload" in request.POST:
|
||||
try:
|
||||
return _handle_iso_upload(request, conn, conn.get_target_path(), compute, pool)
|
||||
return _handle_iso_upload(request, conn, compute, pool)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue