| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, | |
| # software distributed under the License is distributed on an | |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| # KIND, either express or implied. See the License for the | |
| # specific language governing permissions and limitations | |
| # under the License. | |
| import logging | |
| import socket | |
| from typing import Any, Dict, List, Optional, Tuple | |
| import nbd | |
| from ..constants import ( | |
| CHUNK_SIZE, | |
| NBD_BLOCK_STATUS_CHUNK, | |
| NBD_STATE_DIRTY, | |
| NBD_STATE_HOLE, | |
| NBD_STATE_ZERO, | |
| ) | |
| from ..util import coalesce_allocation_extents, merge_dirty_zero_extents | |
| from .base import BackendSession, ImageBackend | |
| def _entries_to_pairs(entries: Any) -> List[Tuple[int, int]]: | |
| """ | |
| Normalize block status callback entries to (length, flags) pairs. | |
| block_status delivers a flat list [length, flags, ...] while | |
| block_status_64 delivers a list of (length, flags) tuples. | |
| """ | |
| raw = list(entries) | |
| if not raw: | |
| return [] | |
| if isinstance(raw[0], (tuple, list)): | |
| return [(int(length), int(flags)) for length, flags in raw] | |
| if isinstance(raw[0], int): | |
| return [(int(raw[i]), int(raw[i + 1])) for i in range(0, len(raw) - 1, 2)] | |
| raise TypeError( | |
| f"unrecognized block status entry type: {type(raw[0]).__name__}" | |
| ) | |
| class NbdConnection: | |
| """ | |
| Low-level helper to connect to an NBD server over a Unix socket. | |
| Opens a fresh handle per connection. | |
| """ | |
| def __init__( | |
| self, | |
| socket_path: str, | |
| export: Optional[str], | |
| need_block_status: bool = False, | |
| extra_meta_contexts: Optional[List[str]] = None, | |
| ): | |
| self._nbd = nbd.NBD() | |
| if export and hasattr(self._nbd, "set_export_name"): | |
| self._nbd.set_export_name(export) | |
| if need_block_status and hasattr(self._nbd, "add_meta_context"): | |
| for ctx in ["base:allocation"] + (extra_meta_contexts or []): | |
| try: | |
| self._nbd.add_meta_context(ctx) | |
| except Exception as e: | |
| logging.warning("add_meta_context %r failed: %r", ctx, e) | |
| self._nbd.connect_unix(socket_path) | |
| def _connect_existing_socket(self, sock: socket.socket) -> None: | |
| last_err: Optional[BaseException] = None | |
| if hasattr(self._nbd, "connect_socket"): | |
| try: | |
| self._nbd.connect_socket(sock) | |
| return | |
| except Exception as e: | |
| last_err = e | |
| try: | |
| self._nbd.connect_socket(sock.fileno()) | |
| return | |
| except Exception as e2: | |
| last_err = e2 | |
| if hasattr(self._nbd, "connect_fd"): | |
| try: | |
| self._nbd.connect_fd(sock.fileno()) | |
| return | |
| except Exception as e: | |
| last_err = e | |
| raise RuntimeError( | |
| "Unable to connect libnbd using existing socket/fd; " | |
| f"binding missing connect_socket/connect_fd or call failed: {last_err!r}" | |
| ) | |
| def size(self) -> int: | |
| return int(self._nbd.get_size()) | |
| def get_capabilities(self) -> Dict[str, bool]: | |
| """ | |
| Query NBD export capabilities (read_only, can_flush, can_zero) from the | |
| server handshake. Uses getattr for binding name variations. | |
| """ | |
| out: Dict[str, bool] = { | |
| "read_only": True, | |
| "can_flush": False, | |
| "can_zero": False, | |
| } | |
| for name, keys in [ | |
| ("read_only", ("is_read_only", "get_read_only")), | |
| ("can_flush", ("can_flush", "get_can_flush")), | |
| ("can_zero", ("can_zero", "get_can_zero")), | |
| ]: | |
| for attr in keys: | |
| if hasattr(self._nbd, attr): | |
| try: | |
| val = getattr(self._nbd, attr)() | |
| out[name] = bool(val) | |
| except Exception: | |
| pass | |
| break | |
| return out | |
| def pread(self, length: int, offset: int) -> bytes: | |
| try: | |
| return self._nbd.pread(length, offset) | |
| except TypeError: | |
| return self._nbd.pread(offset, length) | |
| def pwrite(self, buf: bytes, offset: int) -> None: | |
| try: | |
| self._nbd.pwrite(buf, offset) | |
| except TypeError: | |
| self._nbd.pwrite(offset, buf) | |
| def pzero(self, offset: int, size: int) -> None: | |
| """ | |
| Zero a byte range. Uses NBD WRITE_ZEROES when available, | |
| otherwise falls back to writing zero bytes via pwrite. | |
| """ | |
| if size <= 0: | |
| return | |
| for fn_name in ("pwrite_zeros", "zero"): | |
| if not hasattr(self._nbd, fn_name): | |
| continue | |
| fn = getattr(self._nbd, fn_name) | |
| try: | |
| fn(size, offset) | |
| return | |
| except TypeError: | |
| try: | |
| fn(offset, size) | |
| return | |
| except TypeError: | |
| pass | |
| remaining = size | |
| pos = offset | |
| zero_buf = b"\x00" * min(CHUNK_SIZE, size) | |
| while remaining > 0: | |
| chunk = min(len(zero_buf), remaining) | |
| self.pwrite(zero_buf[:chunk], pos) | |
| pos += chunk | |
| remaining -= chunk | |
| def flush(self) -> None: | |
| if hasattr(self._nbd, "flush"): | |
| self._nbd.flush() | |
| return | |
| if hasattr(self._nbd, "fsync"): | |
| self._nbd.fsync() | |
| return | |
| raise RuntimeError("libnbd binding has no flush/fsync method") | |
| def get_allocation_extents(self) -> List[Dict[str, Any]]: | |
| """ | |
| Query base:allocation and return all extents as | |
| [{"start": ..., "length": ..., "zero": bool}, ...]. | |
| """ | |
| size = self.size() | |
| if size == 0: | |
| return [] | |
| if not hasattr(self._nbd, "block_status") and not hasattr( | |
| self._nbd, "block_status_64" | |
| ): | |
| return [{"start": 0, "length": size, "zero": False}] | |
| if hasattr(self._nbd, "can_meta_context") and not self._nbd.can_meta_context( | |
| "base:allocation" | |
| ): | |
| return [{"start": 0, "length": size, "zero": False}] | |
| allocation_extents: List[Dict[str, Any]] = [] | |
| chunk = min(size, NBD_BLOCK_STATUS_CHUNK) | |
| offset = 0 | |
| def extent_cb(*args: Any, **kwargs: Any) -> int: | |
| if len(args) < 3: | |
| return 0 | |
| metacontext, off, entries = args[0], args[1], args[2] | |
| if metacontext != "base:allocation" or entries is None: | |
| return 0 | |
| current = off | |
| try: | |
| for length, flags in _entries_to_pairs(entries): | |
| zero = (flags & (NBD_STATE_HOLE | NBD_STATE_ZERO)) != 0 | |
| allocation_extents.append( | |
| {"start": current, "length": length, "zero": zero} | |
| ) | |
| current += length | |
| except (TypeError, ValueError, IndexError) as e: | |
| logging.warning( | |
| "get_allocation_extents: unparseable block status entries: %r", e | |
| ) | |
| return 0 | |
| block_status_fn = getattr( | |
| self._nbd, "block_status_64", getattr(self._nbd, "block_status", None) | |
| ) | |
| if block_status_fn is None: | |
| return [{"start": 0, "length": size, "zero": False}] | |
| try: | |
| while offset < size: | |
| count = min(chunk, size - offset) | |
| try: | |
| block_status_fn(count, offset, extent_cb) | |
| except TypeError: | |
| block_status_fn(offset, count, extent_cb) | |
| offset += count | |
| except Exception as e: | |
| logging.warning("get_allocation_extents block_status failed: %r", e) | |
| return [{"start": 0, "length": size, "zero": False}] | |
| if not allocation_extents: | |
| return [{"start": 0, "length": size, "zero": False}] | |
| return coalesce_allocation_extents(allocation_extents) | |
| def get_extents_dirty_and_zero( | |
| self, dirty_bitmap_context: str | |
| ) -> List[Dict[str, Any]]: | |
| """ | |
| Query block status for base:allocation and a dirty bitmap context, | |
| merge boundaries, and return extents with dirty and zero flags. | |
| """ | |
| size = self.size() | |
| if size == 0: | |
| return [] | |
| if not hasattr(self._nbd, "block_status") and not hasattr( | |
| self._nbd, "block_status_64" | |
| ): | |
| return self._fallback_dirty_zero_extents(size) | |
| if hasattr(self._nbd, "can_meta_context"): | |
| if not self._nbd.can_meta_context("base:allocation"): | |
| return self._fallback_dirty_zero_extents(size) | |
| if not self._nbd.can_meta_context(dirty_bitmap_context): | |
| logging.warning( | |
| "dirty bitmap context %r not negotiated", dirty_bitmap_context | |
| ) | |
| return self._fallback_dirty_zero_extents(size) | |
| allocation_extents: List[Tuple[int, int, bool]] = [] | |
| dirty_extents: List[Tuple[int, int, bool]] = [] | |
| chunk = min(size, NBD_BLOCK_STATUS_CHUNK) | |
| offset = 0 | |
| def extent_cb(*args: Any, **kwargs: Any) -> int: | |
| if len(args) < 3: | |
| return 0 | |
| metacontext, off, entries = args[0], args[1], args[2] | |
| if entries is None or not hasattr(entries, "__iter__"): | |
| return 0 | |
| current = off | |
| try: | |
| for length, flags in _entries_to_pairs(entries): | |
| if metacontext == "base:allocation": | |
| zero = (flags & (NBD_STATE_HOLE | NBD_STATE_ZERO)) != 0 | |
| allocation_extents.append((current, length, zero)) | |
| elif metacontext == dirty_bitmap_context: | |
| dirty = (flags & NBD_STATE_DIRTY) != 0 | |
| dirty_extents.append((current, length, dirty)) | |
| current += length | |
| except (TypeError, ValueError, IndexError) as e: | |
| logging.warning( | |
| "get_extents_dirty_and_zero: unparseable block status entries: %r", | |
| e, | |
| ) | |
| return 0 | |
| block_status_fn = getattr( | |
| self._nbd, "block_status_64", getattr(self._nbd, "block_status", None) | |
| ) | |
| if block_status_fn is None: | |
| return self._fallback_dirty_zero_extents(size) | |
| try: | |
| while offset < size: | |
| count = min(chunk, size - offset) | |
| try: | |
| block_status_fn(count, offset, extent_cb) | |
| except TypeError: | |
| block_status_fn(offset, count, extent_cb) | |
| offset += count | |
| except Exception as e: | |
| logging.warning("get_extents_dirty_and_zero block_status failed: %r", e) | |
| return self._fallback_dirty_zero_extents(size) | |
| return merge_dirty_zero_extents(allocation_extents, dirty_extents, size) | |
| @staticmethod | |
| def _fallback_dirty_zero_extents(size: int) -> List[Dict[str, Any]]: | |
| return [{"start": 0, "length": size, "dirty": False, "zero": False}] | |
| def close(self) -> None: | |
| try: | |
| if hasattr(self._nbd, "shutdown"): | |
| self._nbd.shutdown() | |
| except Exception: | |
| pass | |
| def __enter__(self) -> "NbdConnection": | |
| return self | |
| def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: | |
| self.close() | |
| class NbdSession(BackendSession): | |
| """ | |
| Holds a single NbdConnection open for the duration of a streaming operation. | |
| Raises RuntimeError if pread returns empty data (NBD should never do this). | |
| """ | |
| def __init__(self, conn: NbdConnection): | |
| self._conn = conn | |
| def size(self) -> int: | |
| return self._conn.size() | |
| def read(self, offset: int, length: int) -> bytes: | |
| data = self._conn.pread(length, offset) | |
| if not data: | |
| raise RuntimeError("backend returned empty read") | |
| return data | |
| def close(self) -> None: | |
| self._conn.close() | |
| class NbdBackend(ImageBackend): | |
| """ | |
| ImageBackend implementation that proxies to an NBD server via Unix socket. | |
| Each public method opens a fresh NbdConnection (per the original design). | |
| """ | |
| def __init__( | |
| self, | |
| socket_path: str, | |
| export: Optional[str] = None, | |
| export_bitmap: Optional[str] = None, | |
| ): | |
| self._socket_path = socket_path | |
| self._export = export | |
| self._export_bitmap = export_bitmap | |
| @property | |
| def supports_extents(self) -> bool: | |
| return True | |
| @property | |
| def supports_range_write(self) -> bool: | |
| return True | |
| @property | |
| def export_bitmap(self) -> Optional[str]: | |
| return self._export_bitmap | |
| def _connect( | |
| self, | |
| need_block_status: bool = False, | |
| extra_meta_contexts: Optional[List[str]] = None, | |
| ) -> NbdConnection: | |
| return NbdConnection( | |
| self._socket_path, | |
| self._export, | |
| need_block_status=need_block_status, | |
| extra_meta_contexts=extra_meta_contexts, | |
| ) | |
| def size(self) -> int: | |
| with self._connect() as conn: | |
| return conn.size() | |
| def read(self, offset: int, length: int) -> bytes: | |
| with self._connect() as conn: | |
| return conn.pread(length, offset) | |
| def write(self, data: bytes, offset: int) -> None: | |
| with self._connect() as conn: | |
| conn.pwrite(data, offset) | |
| def write_full(self, stream: Any, content_length: int, flush: bool) -> int: | |
| bytes_written = 0 | |
| with self._connect() as conn: | |
| offset = 0 | |
| remaining = content_length | |
| while remaining > 0: | |
| chunk = stream.read(min(CHUNK_SIZE, remaining)) | |
| if not chunk: | |
| raise IOError( | |
| f"request body ended early at {offset} bytes" | |
| ) | |
| conn.pwrite(chunk, offset) | |
| offset += len(chunk) | |
| remaining -= len(chunk) | |
| bytes_written += len(chunk) | |
| if flush: | |
| conn.flush() | |
| return bytes_written | |
| def write_range(self, stream: Any, start_off: int, content_length: int) -> int: | |
| """ | |
| Write *content_length* bytes from *stream* to the image starting at *start_off*. | |
| Returns bytes written. Raises ValueError if offset/length is out of bounds. | |
| """ | |
| bytes_written = 0 | |
| with self._connect() as conn: | |
| image_size = conn.size() | |
| if start_off >= image_size: | |
| raise ValueError(f"offset {start_off} >= image size {image_size}") | |
| max_len = image_size - start_off | |
| if content_length > max_len: | |
| raise ValueError( | |
| f"content_length {content_length} exceeds available space {max_len}" | |
| ) | |
| offset = start_off | |
| remaining = content_length | |
| while remaining > 0: | |
| chunk = stream.read(min(CHUNK_SIZE, remaining)) | |
| if not chunk: | |
| raise IOError( | |
| f"request body ended early at {bytes_written} bytes" | |
| ) | |
| conn.pwrite(chunk, offset) | |
| n = len(chunk) | |
| offset += n | |
| remaining -= n | |
| bytes_written += n | |
| return bytes_written | |
| def flush(self) -> None: | |
| with self._connect() as conn: | |
| conn.flush() | |
| def zero(self, offset: int, length: int) -> None: | |
| with self._connect() as conn: | |
| image_size = conn.size() | |
| if offset >= image_size: | |
| raise ValueError("offset must be less than image size") | |
| zero_size = min(length, image_size - offset) | |
| conn.pzero(offset, zero_size) | |
| def get_capabilities(self) -> Dict[str, bool]: | |
| with self._connect() as conn: | |
| return conn.get_capabilities() | |
| def get_allocation_extents(self) -> List[Dict[str, Any]]: | |
| with self._connect(need_block_status=True) as conn: | |
| return conn.get_allocation_extents() | |
| def get_dirty_extents(self, dirty_bitmap_context: str) -> List[Dict[str, Any]]: | |
| extra_contexts: List[str] = [dirty_bitmap_context] | |
| with self._connect( | |
| need_block_status=True, extra_meta_contexts=extra_contexts | |
| ) as conn: | |
| return conn.get_extents_dirty_and_zero(dirty_bitmap_context) | |
| def open_session(self) -> NbdSession: | |
| return NbdSession(self._connect()) | |
| def close(self) -> None: | |
| pass |