1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/storages/upload.py
catborise 7b5c2cd057 perf(storages): ISO chunks to an SSH compute reuse the SSH connection
Each 32 MiB chunk is its own request and opened a new SSH connection and
SFTP session (~200 ms). A worker now keeps its connection to the host for
the next chunk; each request still opens its own SFTP session (~50 ms),
so an SFTP error never reaches the next one. A connection is not reused
after an error, when the host dropped it or once it is 10 minutes old; an
idle one is closed after a minute, and a forked worker leaves the parent's
to the parent. The SFTP start, which paramiko waits for without a deadline,
is ended after 10 s. Live: 256 MiB in 8 chunks 4.5-4.9 s -> 3.7 s.
2026-10-09 23:22:15 +03:00

835 lines
33 KiB
Python

"""Chunked ISO upload operations shared by the storage view."""
import atexit
import fcntl
import json
import logging
import os
import posixpath
import re
import stat
import tempfile
import threading
import time
from xml.sax.saxutils import escape
import paramiko
from django.utils.translation import gettext_lazy as _
from vrtManager.connection import CONN_SSH, CONN_SOCKET, CONN_TCP, CONN_TLS
logger = logging.getLogger(__name__)
LOCK_TIMEOUT_SECONDS = 15
SSH_CONNECT_TIMEOUT_SECONDS = 10
SSH_IO_TIMEOUT_SECONDS = 30
SSH_IDLE_SECONDS = 60
SSH_MAX_AGE_SECONDS = 600
class _SSHPool:
"""SSH connections to computes, kept for the next chunk of this process
(one HTTP request per chunk; a new connection costs ~200 ms). A request
takes a connection for itself and opens its own SFTP session on it. An
idle connection is closed after SSH_IDLE_SECONDS, and none is reused
once it is SSH_MAX_AGE_SECONDS old, so host keys and logins are checked
again."""
def __init__(self):
self._lock = threading.Lock()
self._idle = {} # key -> (client, opened, timer)
@staticmethod
def _usable(client, opened):
transport = client.get_transport()
return transport is not None and transport.is_active() and time.monotonic() - opened < SSH_MAX_AGE_SECONDS
def take(self, key):
"""An idle connection and when it was opened, or None"""
with self._lock:
entry = self._idle.pop(key, None)
if entry is None:
return None
client, opened, timer = entry
timer.cancel()
if self._usable(client, opened):
return client, opened
client.close()
return None
def give(self, key, client, opened):
if not self._usable(client, opened):
client.close()
return
timer = threading.Timer(SSH_IDLE_SECONDS, lambda: self._expire(key, timer))
timer.daemon = True
with self._lock:
kept = key not in self._idle
if kept:
try:
timer.start()
except RuntimeError: # no thread for the timer: not kept
kept = False
else:
self._idle[key] = (client, opened, timer)
if not kept:
client.close()
def _expire(self, key, timer):
with self._lock:
entry = self._idle.get(key)
if entry is None or entry[2] is not timer: # taken since, perhaps given back
return
del self._idle[key]
entry[0].close()
def close_all(self):
with self._lock:
entries, self._idle = list(self._idle.values()), {}
for client, _opened, timer in entries:
timer.cancel()
client.close()
def forget(self):
"""For a forked child: drop the parent's connections without ending
their sessions; the parent keeps using them"""
entries, self._lock, self._idle = list(self._idle.values()), threading.Lock(), {}
for client, _opened, _timer in entries:
transport = client.get_transport()
if transport is not None:
transport.atfork()
_ssh_pool = _SSHPool()
os.register_at_fork(after_in_child=_ssh_pool.forget)
atexit.register(_ssh_pool.close_all)
def _fsync_dir(dir_path):
if not dir_path or not os.path.isdir(dir_path):
return
try:
dir_fd = os.open(dir_path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError:
pass
def _atomic_finalize_local(target_temp, target_final):
parent_dir = os.path.dirname(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"))
except OSError as link_err:
# 2. Secondary: Linux kernel renameat2 with RENAME_NOREPLACE (flag 1)
try:
import ctypes
libc = ctypes.CDLL("libc.so.6", use_errno=True)
ret = libc.renameat2(
ctypes.c_int(-100),
target_temp.encode("utf-8"),
ctypes.c_int(-100),
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):
pass
# 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))
def _atomic_finalize_ssh(sftp, target_temp, target_final):
"""
Finalize with standard SFTP rename, which requires an unused destination.
Atomic visibility depends on the remote SFTP server and filesystem.
"""
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):
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)}"
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"))
def _write_pipelined(stream, chunk, offset):
"""Write chunk to an SFTP file at offset without waiting for the answer to
each 32 KiB write, raising an error as soon as one is read. Each write is
registered to the file, as paramiko does for prefetched reads, so its
answer reaches the file in whatever order it comes. (paramiko's own
pipelined writes drop their errors.)"""
sftp = stream.sftp
for data in chunk.chunks():
for start in range(0, len(data), stream.MAX_REQUEST_SIZE):
piece = data[start:start + stream.MAX_REQUEST_SIZE]
sftp._async_request(stream, paramiko.sftp.CMD_WRITE, stream.handle, paramiko.sftp.int64(offset), piece)
offset += len(piece)
# answers are taken as they come, so the server never waits for us to read
while sftp.sock.recv_ready():
sftp._read_response()
stream._check_exception()
sftp._finish_responses(stream)
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."""
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.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):
if self.remote:
contained = target.startswith(self.base.rstrip("/") + "/")
else:
contained = os.path.commonpath((self.base, target)) == self.base
if not contained:
raise PermissionError(_("Security issues with file uploading: path traversal detected"))
self.ssh = None
self.sftp = None
def __enter__(self):
if self.remote:
hostname, separator, port = self.conn.host.partition(":")
self.key = (hostname, int(port) if separator else 22, self.conn.login, self.conn.passwd)
taken = _ssh_pool.take(self.key)
if taken is not None:
self.ssh, self.opened = taken
try:
self._open_sftp()
return self
except Exception: # a connection the host dropped: open a new one
self.ssh.close()
self.ssh, self.opened = self._connect(), time.monotonic()
try:
self._open_sftp()
except Exception:
self.ssh.close()
raise
return self
def _connect(self):
hostname, port = self.key[:2]
ssh = paramiko.SSHClient()
try:
ssh.load_system_host_keys()
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
ssh.connect(
hostname=hostname, port=port,
username=self.conn.login, password=self.conn.passwd,
timeout=SSH_CONNECT_TIMEOUT_SECONDS,
banner_timeout=SSH_CONNECT_TIMEOUT_SECONDS,
auth_timeout=SSH_CONNECT_TIMEOUT_SECONDS,
channel_timeout=SSH_CONNECT_TIMEOUT_SECONDS,
)
except Exception:
ssh.close()
raise
return ssh
def _open_sftp(self):
# paramiko waits for the SFTP subsystem without a deadline; on a dead
# connection closing it ends the wait with an error
watchdog = threading.Timer(SSH_CONNECT_TIMEOUT_SECONDS, self.ssh.close)
watchdog.daemon = True
watchdog.start()
try:
self.sftp = self.ssh.open_sftp()
finally:
watchdog.cancel()
transport = self.ssh.get_transport()
if transport is None or not transport.is_active():
self.sftp.close()
raise paramiko.SSHException(_("The SFTP session did not start in time"))
self.sftp.get_channel().settimeout(SSH_IO_TIMEOUT_SECONDS)
def __exit__(self, exc_type, *_):
if self.ssh is None:
return
try:
self.sftp.close()
except Exception:
self.ssh.close()
raise
# after an error the connection is not trusted for the next chunk
if exc_type is None:
_ssh_pool.give(self.key, self.ssh, self.opened)
else:
self.ssh.close()
def info(self, path):
try:
info = self.sftp.lstat(path) if self.remote else os.lstat(path)
except FileNotFoundError:
return None
if stat.S_ISLNK(info.st_mode):
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 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 write_chunk(self, chunk, offset, new):
if self.remote:
with self.sftp.open(self.part, "wb" if new else "r+b") as stream:
if not new:
stream.seek(offset)
stream.truncate(offset)
# ~5x faster on a LAN than waiting for each 32 KiB write
offset = _write_pipelined(stream, chunk, offset)
return offset
flags = (os.O_WRONLY | os.O_CREAT | os.O_EXCL) if new else os.O_RDWR
flags |= getattr(os, "O_NOFOLLOW", 0)
fd = os.open(self.part, flags, 0o600)
with open(fd, "wb" if new else "r+b", closefd=True) as stream:
if not new:
stream.seek(offset)
stream.truncate()
for data in chunk.chunks():
stream.write(data)
offset += len(data)
stream.flush()
os.fsync(stream.fileno())
return offset
def finalize(self):
if self.remote:
_atomic_finalize_ssh(self.sftp, self.part, self.final)
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)
return
except BlockingIOError:
if time.monotonic() >= deadline:
raise TimeoutError(_("Timed out waiting for upload session lock"))
time.sleep(0.05)
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))
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"))
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):
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(),
}
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}
)
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"))
# 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"))
volume = conn.get_volume(file_name)
stream = conn.wvm.newStream(0)
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()
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)
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"))
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)
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()
return
if chunk_index != expected:
raise ValueError(
_("Invalid chunk sequence. Expected chunk %(exp)d, got %(got)d")
% {"exp": expected, "got": chunk_index}
)
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)