mirror of https://github.com/apache/cloudstack.git
900 lines
34 KiB
Python
900 lines
34 KiB
Python
# 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 json
|
|
import logging
|
|
import re
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
from urllib.parse import parse_qs
|
|
|
|
from .backends import NbdBackend, create_backend
|
|
from .config import TransferRegistry
|
|
from .constants import CHUNK_SIZE, MAX_PARALLEL_READS, MAX_PARALLEL_WRITES, MAX_PATCH_JSON_SIZE
|
|
from .util import is_fallback_dirty_response, json_bytes, now_s
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
"""
|
|
HTTP request handler for the image server.
|
|
|
|
Routing, HTTP parsing, and response formatting live here.
|
|
All backend I/O is delegated to ImageBackend implementations via the
|
|
create_backend() factory.
|
|
|
|
Class-level attribute _registry is injected
|
|
by the server at startup (see server.py / make_handler()).
|
|
"""
|
|
|
|
server_version = "cloudstack-image-server/1.0"
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
_registry: TransferRegistry
|
|
|
|
_CONTENT_RANGE_RE = re.compile(r"^bytes\s+(\d+)-(\d+)/(?:\*|\d+)$")
|
|
|
|
def log_message(self, fmt: str, *args: Any) -> None:
|
|
logging.info("%s - - %s", self.client_address[0], fmt % args)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Response helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _send_imageio_headers(
|
|
self, allowed_methods: Optional[str] = None
|
|
) -> None:
|
|
if allowed_methods is None:
|
|
allowed_methods = "GET, PUT, OPTIONS"
|
|
self.send_header("Access-Control-Allow-Methods", allowed_methods)
|
|
self.send_header("Accept-Ranges", "bytes")
|
|
|
|
def _send_json(
|
|
self,
|
|
status: int,
|
|
obj: Any,
|
|
allowed_methods: Optional[str] = None,
|
|
) -> None:
|
|
body = json_bytes(obj)
|
|
self.send_response(status)
|
|
self._send_imageio_headers(allowed_methods)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
try:
|
|
self.wfile.write(body)
|
|
except BrokenPipeError:
|
|
logging.error(
|
|
"HTTP response write failure status=%s method=%s path=%s err=%s",
|
|
int(status),
|
|
self.command,
|
|
self.path,
|
|
"client disconnected",
|
|
)
|
|
|
|
def _send_error_json(self, status: int, message: str) -> None:
|
|
logging.error(
|
|
"HTTP failure status=%s method=%s path=%s message=%s",
|
|
int(status),
|
|
self.command,
|
|
self.path,
|
|
message,
|
|
)
|
|
self._send_json(status, {"error": message})
|
|
|
|
def _send_range_not_satisfiable(self, size: int) -> None:
|
|
logging.error(
|
|
"HTTP failure status=%s method=%s path=%s message=%s size=%s",
|
|
int(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE),
|
|
self.command,
|
|
self.path,
|
|
"range not satisfiable",
|
|
size,
|
|
)
|
|
self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
|
|
self._send_imageio_headers()
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Range", f"bytes */{size}")
|
|
body = json_bytes({"error": "range not satisfiable"})
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
try:
|
|
self.wfile.write(body)
|
|
except BrokenPipeError:
|
|
logging.error(
|
|
"HTTP response write failure status=%s method=%s path=%s err=%s",
|
|
int(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE),
|
|
self.command,
|
|
self.path,
|
|
"client disconnected",
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Parsing helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _parse_single_range(self, range_header: str, size: int) -> Tuple[int, int]:
|
|
"""
|
|
Parse a single HTTP byte range (RFC 7233) and return (start, end_inclusive).
|
|
Raises ValueError for invalid headers.
|
|
"""
|
|
if size < 0:
|
|
raise ValueError("invalid size")
|
|
if not range_header:
|
|
raise ValueError("empty Range")
|
|
if "," in range_header:
|
|
raise ValueError("multiple ranges not supported")
|
|
|
|
prefix = "bytes="
|
|
if not range_header.startswith(prefix):
|
|
raise ValueError("only bytes ranges supported")
|
|
spec = range_header[len(prefix):].strip()
|
|
if "-" not in spec:
|
|
raise ValueError("invalid bytes range")
|
|
|
|
left, right = spec.split("-", 1)
|
|
left = left.strip()
|
|
right = right.strip()
|
|
|
|
if left == "":
|
|
if right == "":
|
|
raise ValueError("invalid suffix range")
|
|
try:
|
|
suffix_len = int(right, 10)
|
|
except ValueError as e:
|
|
raise ValueError("invalid suffix length") from e
|
|
if suffix_len <= 0:
|
|
raise ValueError("invalid suffix length")
|
|
if size == 0:
|
|
raise ValueError("unsatisfiable")
|
|
if suffix_len >= size:
|
|
return 0, size - 1
|
|
return size - suffix_len, size - 1
|
|
|
|
try:
|
|
start = int(left, 10)
|
|
except ValueError as e:
|
|
raise ValueError("invalid range start") from e
|
|
if start < 0:
|
|
raise ValueError("invalid range start")
|
|
if start >= size:
|
|
raise ValueError("unsatisfiable")
|
|
|
|
if right == "":
|
|
return start, size - 1
|
|
|
|
try:
|
|
end = int(right, 10)
|
|
except ValueError as e:
|
|
raise ValueError("invalid range end") from e
|
|
if end < start:
|
|
raise ValueError("unsatisfiable")
|
|
if end >= size:
|
|
end = size - 1
|
|
return start, end
|
|
|
|
def _parse_route(self) -> Tuple[Optional[str], Optional[str]]:
|
|
path = self.path.split("?", 1)[0]
|
|
parts = [p for p in path.split("/") if p]
|
|
if len(parts) < 2 or parts[0] != "images":
|
|
return None, None
|
|
image_id = parts[1]
|
|
tail = parts[2] if len(parts) >= 3 else None
|
|
if len(parts) > 3:
|
|
return None, None
|
|
return image_id, tail
|
|
|
|
def _parse_content_range(self, header: str) -> Tuple[int, int]:
|
|
"""
|
|
Parse Content-Range header "bytes start-end/*" or "bytes start-end/size".
|
|
Returns (start, end_inclusive).
|
|
"""
|
|
if not header:
|
|
raise ValueError("empty Content-Range")
|
|
m = self._CONTENT_RANGE_RE.match(header.strip())
|
|
if not m:
|
|
raise ValueError("invalid Content-Range")
|
|
start_s, end_s = m.groups()
|
|
start = int(start_s, 10)
|
|
end = int(end_s, 10)
|
|
if start < 0 or end < start:
|
|
raise ValueError("invalid Content-Range range")
|
|
return start, end
|
|
|
|
def _parse_query(self) -> Dict[str, List[str]]:
|
|
if "?" not in self.path:
|
|
return {}
|
|
query = self.path.split("?", 1)[1]
|
|
return parse_qs(query, keep_blank_values=True)
|
|
|
|
def _image_cfg(self, image_id: str) -> Optional[Dict[str, Any]]:
|
|
return self._registry.get(image_id)
|
|
|
|
# ------------------------------------------------------------------
|
|
# HTTP verb dispatchers
|
|
# ------------------------------------------------------------------
|
|
|
|
def do_OPTIONS(self) -> None:
|
|
image_id, tail = self._parse_route()
|
|
if image_id is None or tail is not None:
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "not found")
|
|
return
|
|
cfg = self._image_cfg(image_id)
|
|
if cfg is None:
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "unknown image_id")
|
|
return
|
|
|
|
with self._registry.request_lifecycle(image_id):
|
|
backend = create_backend(cfg)
|
|
try:
|
|
max_writers = MAX_PARALLEL_WRITES
|
|
if not backend.supports_range_write:
|
|
max_writers = 1
|
|
|
|
if not backend.supports_extents:
|
|
allowed_methods = "GET, PUT, POST, OPTIONS"
|
|
features = ["flush"]
|
|
response = {
|
|
"unix_socket": None,
|
|
"features": features,
|
|
"max_readers": MAX_PARALLEL_READS,
|
|
"max_writers": max_writers,
|
|
}
|
|
self._send_json(HTTPStatus.OK, response, allowed_methods=allowed_methods)
|
|
return
|
|
|
|
read_only = True
|
|
can_flush = False
|
|
can_zero = False
|
|
try:
|
|
caps = backend.get_capabilities()
|
|
read_only = caps["read_only"]
|
|
can_flush = caps["can_flush"]
|
|
can_zero = caps["can_zero"]
|
|
except Exception as e:
|
|
logging.warning("OPTIONS: could not query backend capabilities: %r", e)
|
|
read_only = bool(cfg.get("read_only"))
|
|
if not read_only:
|
|
can_flush = True
|
|
can_zero = True
|
|
|
|
if read_only:
|
|
allowed_methods = "GET, OPTIONS"
|
|
features = ["extents"]
|
|
max_writers = 0
|
|
else:
|
|
allowed_methods = "GET, PUT, PATCH, OPTIONS"
|
|
features = ["extents"]
|
|
if can_zero:
|
|
features.append("zero")
|
|
if can_flush:
|
|
features.append("flush")
|
|
|
|
response = {
|
|
"unix_socket": None,
|
|
"features": features,
|
|
"max_readers": MAX_PARALLEL_READS,
|
|
"max_writers": max_writers,
|
|
}
|
|
self._send_json(HTTPStatus.OK, response, allowed_methods=allowed_methods)
|
|
finally:
|
|
backend.close()
|
|
|
|
def do_GET(self) -> None:
|
|
image_id, tail = self._parse_route()
|
|
if image_id is None:
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "not found")
|
|
return
|
|
|
|
cfg = self._image_cfg(image_id)
|
|
if cfg is None:
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "unknown image_id")
|
|
return
|
|
|
|
if tail == "extents":
|
|
with self._registry.request_lifecycle(image_id):
|
|
query = self._parse_query()
|
|
context = (query.get("context") or [None])[0]
|
|
self._handle_get_extents(image_id, cfg, context=context)
|
|
return
|
|
if tail is not None:
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "not found")
|
|
return
|
|
|
|
range_header = self.headers.get("Range")
|
|
with self._registry.request_lifecycle(image_id):
|
|
self._handle_get_image(image_id, cfg, range_header)
|
|
|
|
def do_PUT(self) -> None:
|
|
image_id, tail = self._parse_route()
|
|
if image_id is None or tail is not None:
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "not found")
|
|
return
|
|
|
|
cfg = self._image_cfg(image_id)
|
|
if cfg is None:
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "unknown image_id")
|
|
return
|
|
|
|
with self._registry.request_lifecycle(image_id):
|
|
if self.headers.get("Range") is not None:
|
|
self._send_error_json(
|
|
HTTPStatus.BAD_REQUEST,
|
|
"Range header not supported for PUT; use Content-Range or PATCH",
|
|
)
|
|
return
|
|
|
|
content_length_hdr = self.headers.get("Content-Length")
|
|
if content_length_hdr is None:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, "Content-Length required")
|
|
return
|
|
try:
|
|
content_length = int(content_length_hdr)
|
|
except ValueError:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid Content-Length")
|
|
return
|
|
if content_length < 0:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid Content-Length")
|
|
return
|
|
|
|
query = self._parse_query()
|
|
flush_param = (query.get("flush") or ["n"])[0].lower()
|
|
flush = flush_param in ("y", "yes", "true", "1")
|
|
|
|
content_range_hdr = self.headers.get("Content-Range")
|
|
if content_range_hdr is not None:
|
|
backend = create_backend(cfg)
|
|
try:
|
|
if not backend.supports_range_write:
|
|
self._send_error_json(
|
|
HTTPStatus.BAD_REQUEST,
|
|
"Content-Range PUT not supported for file backend; use full PUT",
|
|
)
|
|
return
|
|
self._handle_put_range_with_backend(
|
|
image_id,
|
|
backend,
|
|
content_range_hdr,
|
|
content_length,
|
|
flush,
|
|
)
|
|
finally:
|
|
backend.close()
|
|
return
|
|
|
|
self._handle_put_image(image_id, cfg, content_length, flush)
|
|
|
|
def do_POST(self) -> None:
|
|
image_id, tail = self._parse_route()
|
|
if image_id is None:
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "not found")
|
|
return
|
|
|
|
cfg = self._image_cfg(image_id)
|
|
if cfg is None:
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "unknown image_id")
|
|
return
|
|
|
|
if tail == "flush":
|
|
with self._registry.request_lifecycle(image_id):
|
|
self._handle_post_flush(image_id, cfg)
|
|
return
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "not found")
|
|
|
|
def do_PATCH(self) -> None:
|
|
image_id, tail = self._parse_route()
|
|
if image_id is None or tail is not None:
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "not found")
|
|
return
|
|
|
|
cfg = self._image_cfg(image_id)
|
|
if cfg is None:
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "unknown image_id")
|
|
return
|
|
|
|
with self._registry.request_lifecycle(image_id):
|
|
backend = create_backend(cfg)
|
|
try:
|
|
if not backend.supports_range_write:
|
|
self._send_error_json(
|
|
HTTPStatus.BAD_REQUEST,
|
|
"range writes and PATCH not supported for file backend; use PUT for full upload",
|
|
)
|
|
return
|
|
content_type = self.headers.get("Content-Type", "").split(";")[0].strip().lower()
|
|
range_header = self.headers.get("Range")
|
|
|
|
if range_header is not None and content_type != "application/json":
|
|
content_length_hdr = self.headers.get("Content-Length")
|
|
if content_length_hdr is None:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, "Content-Length required")
|
|
return
|
|
try:
|
|
content_length = int(content_length_hdr)
|
|
except ValueError:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid Content-Length")
|
|
return
|
|
if content_length <= 0:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, "Content-Length must be positive")
|
|
return
|
|
self._handle_patch_range_with_backend(
|
|
image_id,
|
|
backend,
|
|
range_header,
|
|
content_length,
|
|
)
|
|
return
|
|
|
|
if content_type != "application/json":
|
|
self._send_error_json(
|
|
HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
|
|
"PATCH requires Content-Type: application/json (for zero/flush) or Range with binary body",
|
|
)
|
|
return
|
|
|
|
content_length_hdr = self.headers.get("Content-Length")
|
|
if content_length_hdr is None:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, "Content-Length required")
|
|
return
|
|
try:
|
|
content_length = int(content_length_hdr)
|
|
except ValueError:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid Content-Length")
|
|
return
|
|
if content_length <= 0 or content_length > MAX_PATCH_JSON_SIZE:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid Content-Length")
|
|
return
|
|
|
|
body = self.rfile.read(content_length)
|
|
if len(body) != content_length:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, "request body truncated")
|
|
return
|
|
|
|
try:
|
|
payload = json.loads(body.decode("utf-8"))
|
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, f"invalid JSON: {e}")
|
|
return
|
|
|
|
if not isinstance(payload, dict):
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, "body must be a JSON object")
|
|
return
|
|
|
|
op = payload.get("op")
|
|
if op == "flush":
|
|
self._handle_post_flush_with_backend(image_id, backend)
|
|
return
|
|
if op != "zero":
|
|
self._send_error_json(
|
|
HTTPStatus.BAD_REQUEST,
|
|
"unsupported op; only \"zero\" and \"flush\" are supported",
|
|
)
|
|
return
|
|
|
|
try:
|
|
size = int(payload.get("size"))
|
|
except (TypeError, ValueError):
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, "missing or invalid \"size\"")
|
|
return
|
|
if size <= 0:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, "\"size\" must be positive")
|
|
return
|
|
|
|
offset = payload.get("offset")
|
|
if offset is None:
|
|
offset = 0
|
|
else:
|
|
try:
|
|
offset = int(offset)
|
|
except (TypeError, ValueError):
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, "invalid \"offset\"")
|
|
return
|
|
if offset < 0:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, "\"offset\" must be non-negative")
|
|
return
|
|
|
|
flush = bool(payload.get("flush", False))
|
|
self._handle_patch_zero_with_backend(
|
|
image_id,
|
|
backend,
|
|
offset=offset,
|
|
size=size,
|
|
flush=flush,
|
|
)
|
|
finally:
|
|
backend.close()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Operation handlers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _handle_get_image(
|
|
self, image_id: str, cfg: Dict[str, Any], range_header: Optional[str]
|
|
) -> None:
|
|
start = now_s()
|
|
bytes_sent = 0
|
|
expected_bytes = 0
|
|
try:
|
|
logging.info("GET start image_id=%s range=%s", image_id, range_header or "-")
|
|
backend = create_backend(cfg)
|
|
session = None
|
|
try:
|
|
session = backend.open_session()
|
|
size = session.size()
|
|
except OSError as e:
|
|
logging.error("GET size error image_id=%s err=%r", image_id, e)
|
|
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, "failed to access image")
|
|
if session is not None:
|
|
session.close()
|
|
backend.close()
|
|
return
|
|
|
|
try:
|
|
start_off = 0
|
|
end_off_incl = size - 1 if size > 0 else -1
|
|
status = HTTPStatus.OK
|
|
content_length = size
|
|
if range_header is not None:
|
|
try:
|
|
start_off, end_off_incl = self._parse_single_range(range_header, size)
|
|
except ValueError as e:
|
|
if "unsatisfiable" in str(e):
|
|
self._send_range_not_satisfiable(size)
|
|
return
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, "invalid Range header")
|
|
return
|
|
status = HTTPStatus.PARTIAL_CONTENT
|
|
content_length = (end_off_incl - start_off) + 1
|
|
expected_bytes = content_length
|
|
|
|
self.send_response(status)
|
|
self._send_imageio_headers()
|
|
self.send_header("Content-Type", "application/octet-stream")
|
|
self.send_header("Content-Length", str(content_length))
|
|
if status == HTTPStatus.PARTIAL_CONTENT:
|
|
self.send_header("Content-Range", f"bytes {start_off}-{end_off_incl}/{size}")
|
|
self.end_headers()
|
|
|
|
offset = start_off
|
|
end_excl = end_off_incl + 1
|
|
while offset < end_excl:
|
|
to_read = min(CHUNK_SIZE, end_excl - offset)
|
|
data = session.read(offset, to_read)
|
|
if not data:
|
|
logging.error(
|
|
"GET short read image_id=%s expected_bytes=%d sent_bytes=%d offset=%d",
|
|
image_id,
|
|
expected_bytes,
|
|
bytes_sent,
|
|
offset,
|
|
)
|
|
self.close_connection = True
|
|
break
|
|
try:
|
|
self.wfile.write(data)
|
|
except BrokenPipeError:
|
|
logging.error(
|
|
"GET client disconnected image_id=%s at=%d expected_bytes=%d sent_bytes=%d",
|
|
image_id,
|
|
offset,
|
|
expected_bytes,
|
|
bytes_sent,
|
|
)
|
|
self.close_connection = True
|
|
break
|
|
offset += len(data)
|
|
bytes_sent += len(data)
|
|
finally:
|
|
session.close()
|
|
backend.close()
|
|
except Exception as e:
|
|
logging.error("GET error image_id=%s err=%r", image_id, e)
|
|
try:
|
|
if not self.wfile.closed:
|
|
self.close_connection = True
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
if expected_bytes > 0 and bytes_sent < expected_bytes:
|
|
logging.error(
|
|
"GET incomplete image_id=%s expected_bytes=%d sent_bytes=%d",
|
|
image_id,
|
|
expected_bytes,
|
|
bytes_sent,
|
|
)
|
|
dur = now_s() - start
|
|
logging.info(
|
|
"GET end image_id=%s bytes=%d duration_s=%.3f", image_id, bytes_sent, dur
|
|
)
|
|
|
|
def _handle_put_image(
|
|
self, image_id: str, cfg: Dict[str, Any], content_length: int, flush: bool
|
|
) -> None:
|
|
start = now_s()
|
|
bytes_written = 0
|
|
try:
|
|
logging.info("PUT start image_id=%s content_length=%d", image_id, content_length)
|
|
backend = create_backend(cfg)
|
|
try:
|
|
bytes_written = backend.write_full(self.rfile, content_length, flush)
|
|
self._send_json(
|
|
HTTPStatus.OK,
|
|
{"ok": True, "bytes_written": bytes_written, "flushed": flush},
|
|
)
|
|
except IOError as e:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, str(e))
|
|
finally:
|
|
backend.close()
|
|
except Exception as e:
|
|
logging.error("PUT error image_id=%s err=%r", image_id, e)
|
|
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, "backend error")
|
|
finally:
|
|
dur = now_s() - start
|
|
logging.info(
|
|
"PUT end image_id=%s bytes=%d duration_s=%.3f", image_id, bytes_written, dur
|
|
)
|
|
|
|
def _handle_put_range(
|
|
self,
|
|
image_id: str,
|
|
cfg: Dict[str, Any],
|
|
content_range: str,
|
|
content_length: int,
|
|
flush: bool,
|
|
) -> None:
|
|
backend = create_backend(cfg)
|
|
try:
|
|
self._handle_put_range_with_backend(
|
|
image_id,
|
|
backend,
|
|
content_range,
|
|
content_length,
|
|
flush,
|
|
)
|
|
finally:
|
|
backend.close()
|
|
|
|
def _handle_put_range_with_backend(
|
|
self,
|
|
image_id: str,
|
|
backend: NbdBackend,
|
|
content_range: str,
|
|
content_length: int,
|
|
flush: bool,
|
|
) -> None:
|
|
start = now_s()
|
|
bytes_written = 0
|
|
try:
|
|
logging.info(
|
|
"PUT range start image_id=%s Content-Range=%s content_length=%d flush=%s",
|
|
image_id, content_range, content_length, flush,
|
|
)
|
|
try:
|
|
start_off, _end_inclusive = self._parse_content_range(content_range)
|
|
except ValueError as e:
|
|
self._send_error_json(
|
|
HTTPStatus.BAD_REQUEST, f"invalid Content-Range header: {e}"
|
|
)
|
|
return
|
|
|
|
try:
|
|
bytes_written = backend.write_range(self.rfile, start_off, content_length)
|
|
if flush:
|
|
backend.flush()
|
|
self._send_json(
|
|
HTTPStatus.OK,
|
|
{"ok": True, "bytes_written": bytes_written, "flushed": flush},
|
|
)
|
|
except ValueError:
|
|
image_size = backend.size()
|
|
self._send_range_not_satisfiable(image_size)
|
|
except IOError as e:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, str(e))
|
|
except Exception as e:
|
|
logging.error("PUT range error image_id=%s err=%r", image_id, e)
|
|
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, "backend error")
|
|
finally:
|
|
dur = now_s() - start
|
|
logging.info(
|
|
"PUT range end image_id=%s bytes=%d duration_s=%.3f flush=%s",
|
|
image_id, bytes_written, dur, flush,
|
|
)
|
|
|
|
def _handle_get_extents(
|
|
self, image_id: str, cfg: Dict[str, Any], context: Optional[str] = None
|
|
) -> None:
|
|
backend = create_backend(cfg)
|
|
try:
|
|
self._handle_get_extents_with_backend(image_id, backend, context=context)
|
|
finally:
|
|
backend.close()
|
|
|
|
def _handle_get_extents_with_backend(
|
|
self, image_id: str, backend: NbdBackend, context: Optional[str] = None
|
|
) -> None:
|
|
start = now_s()
|
|
try:
|
|
logging.info("EXTENTS start image_id=%s context=%s", image_id, context)
|
|
if not backend.supports_extents:
|
|
self._send_error_json(
|
|
HTTPStatus.BAD_REQUEST, "extents not supported for file backend"
|
|
)
|
|
return
|
|
if context == "dirty":
|
|
export_bitmap = backend.export_bitmap
|
|
if not export_bitmap:
|
|
allocation = backend.get_allocation_extents()
|
|
extents: List[Dict[str, Any]] = [
|
|
{"start": e["start"], "length": e["length"], "dirty": True, "zero": e["zero"]}
|
|
for e in allocation
|
|
]
|
|
else:
|
|
dirty_bitmap_ctx = f"qemu:dirty-bitmap:{export_bitmap}"
|
|
extents = backend.get_dirty_extents(dirty_bitmap_ctx)
|
|
if is_fallback_dirty_response(extents):
|
|
allocation = backend.get_allocation_extents()
|
|
extents = [
|
|
{
|
|
"start": e["start"],
|
|
"length": e["length"],
|
|
"dirty": True,
|
|
"zero": e["zero"],
|
|
}
|
|
for e in allocation
|
|
]
|
|
else:
|
|
extents = backend.get_allocation_extents()
|
|
self._send_json(HTTPStatus.OK, extents)
|
|
except Exception as e:
|
|
logging.error("EXTENTS error image_id=%s err=%r", image_id, e)
|
|
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, "backend error")
|
|
finally:
|
|
dur = now_s() - start
|
|
logging.info("EXTENTS end image_id=%s duration_s=%.3f", image_id, dur)
|
|
|
|
def _handle_post_flush(self, image_id: str, cfg: Dict[str, Any]) -> None:
|
|
backend = create_backend(cfg)
|
|
try:
|
|
self._handle_post_flush_with_backend(image_id, backend)
|
|
finally:
|
|
backend.close()
|
|
|
|
def _handle_post_flush_with_backend(self, image_id: str, backend: NbdBackend) -> None:
|
|
start = now_s()
|
|
try:
|
|
logging.info("FLUSH start image_id=%s", image_id)
|
|
backend.flush()
|
|
self._send_json(HTTPStatus.OK, {"ok": True})
|
|
except Exception as e:
|
|
logging.error("FLUSH error image_id=%s err=%r", image_id, e)
|
|
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, "backend error")
|
|
finally:
|
|
dur = now_s() - start
|
|
logging.info("FLUSH end image_id=%s duration_s=%.3f", image_id, dur)
|
|
|
|
def _handle_patch_zero(
|
|
self,
|
|
image_id: str,
|
|
cfg: Dict[str, Any],
|
|
offset: int,
|
|
size: int,
|
|
flush: bool,
|
|
) -> None:
|
|
backend = create_backend(cfg)
|
|
try:
|
|
self._handle_patch_zero_with_backend(image_id, backend, offset, size, flush)
|
|
finally:
|
|
backend.close()
|
|
|
|
def _handle_patch_zero_with_backend(
|
|
self,
|
|
image_id: str,
|
|
backend: NbdBackend,
|
|
offset: int,
|
|
size: int,
|
|
flush: bool,
|
|
) -> None:
|
|
start = now_s()
|
|
try:
|
|
logging.info(
|
|
"PATCH zero start image_id=%s offset=%d size=%d flush=%s",
|
|
image_id, offset, size, flush,
|
|
)
|
|
backend.zero(offset, size)
|
|
if flush:
|
|
backend.flush()
|
|
self._send_json(HTTPStatus.OK, {"ok": True})
|
|
except ValueError as e:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, str(e))
|
|
except Exception as e:
|
|
logging.error("PATCH zero error image_id=%s err=%r", image_id, e)
|
|
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, "backend error")
|
|
finally:
|
|
dur = now_s() - start
|
|
logging.info("PATCH zero end image_id=%s duration_s=%.3f", image_id, dur)
|
|
|
|
def _handle_patch_range(
|
|
self,
|
|
image_id: str,
|
|
cfg: Dict[str, Any],
|
|
range_header: str,
|
|
content_length: int,
|
|
) -> None:
|
|
backend = create_backend(cfg)
|
|
try:
|
|
self._handle_patch_range_with_backend(
|
|
image_id,
|
|
backend,
|
|
range_header,
|
|
content_length,
|
|
)
|
|
finally:
|
|
backend.close()
|
|
|
|
def _handle_patch_range_with_backend(
|
|
self,
|
|
image_id: str,
|
|
backend: NbdBackend,
|
|
range_header: str,
|
|
content_length: int,
|
|
) -> None:
|
|
start = now_s()
|
|
bytes_written = 0
|
|
try:
|
|
logging.info(
|
|
"PATCH range start image_id=%s range=%s content_length=%d",
|
|
image_id, range_header, content_length,
|
|
)
|
|
try:
|
|
image_size = backend.size()
|
|
try:
|
|
start_off, end_inclusive = self._parse_single_range(
|
|
range_header, image_size
|
|
)
|
|
except ValueError as e:
|
|
if "unsatisfiable" in str(e).lower():
|
|
self._send_range_not_satisfiable(image_size)
|
|
else:
|
|
self._send_error_json(
|
|
HTTPStatus.BAD_REQUEST, f"invalid Range header: {e}"
|
|
)
|
|
return
|
|
expected_len = end_inclusive - start_off + 1
|
|
if content_length != expected_len:
|
|
self._send_error_json(
|
|
HTTPStatus.BAD_REQUEST,
|
|
f"Content-Length ({content_length}) must equal range length ({expected_len})",
|
|
)
|
|
return
|
|
nbd_backend: NbdBackend = backend # type: ignore[assignment]
|
|
bytes_written = nbd_backend.write_range(self.rfile, start_off, content_length)
|
|
self._send_json(HTTPStatus.OK, {"ok": True, "bytes_written": bytes_written})
|
|
except ValueError:
|
|
image_size = backend.size()
|
|
self._send_range_not_satisfiable(image_size)
|
|
except IOError as e:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, str(e))
|
|
except Exception as e:
|
|
logging.error("PATCH range error image_id=%s err=%r", image_id, e)
|
|
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, "backend error")
|
|
finally:
|
|
dur = now_s() - start
|
|
logging.info(
|
|
"PATCH range end image_id=%s bytes=%d duration_s=%.3f",
|
|
image_id, bytes_written, dur,
|
|
)
|