cloudstack/scripts/vm/hypervisor/kvm/imageserver/handler.py

843 lines
32 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 .concurrency import ConcurrencyManager
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 attributes _concurrency and _registry are injected
by the server at startup (see server.py / make_handler()).
"""
server_version = "cloudstack-image-server/1.0"
server_protocol = "HTTP/1.1"
_concurrency: ConcurrencyManager
_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.address_string(), 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:
pass
def _send_error_json(self, status: int, message: str) -> None:
self._send_json(status, {"error": message})
def _send_range_not_satisfiable(self, size: int) -> None:
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:
pass
# ------------------------------------------------------------------
# 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
backend = create_backend(cfg)
try:
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_PARALLEL_WRITES,
}
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")
max_writers = MAX_PARALLEL_WRITES
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":
backend = create_backend(cfg)
try:
if not backend.supports_extents:
self._send_error_json(
HTTPStatus.BAD_REQUEST, "extents not supported for file backend"
)
return
finally:
backend.close()
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")
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
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
finally:
backend.close()
self._handle_put_range(image_id, cfg, content_range_hdr, content_length, flush)
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":
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
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
finally:
backend.close()
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(image_id, cfg, 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(image_id, cfg)
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(image_id, cfg, offset=offset, size=size, flush=flush)
# ------------------------------------------------------------------
# Operation handlers
# ------------------------------------------------------------------
def _handle_get_image(
self, image_id: str, cfg: Dict[str, Any], range_header: Optional[str]
) -> None:
if not self._concurrency.acquire_read(image_id):
self._send_error_json(HTTPStatus.SERVICE_UNAVAILABLE, "too many parallel reads")
return
start = now_s()
bytes_sent = 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
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:
break
try:
self.wfile.write(data)
except BrokenPipeError:
logging.info("GET client disconnected image_id=%s at=%d", image_id, offset)
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:
self._concurrency.release_read(image_id)
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:
lock = self._concurrency.get_image_lock(image_id)
lock.acquire()
if not self._concurrency.acquire_write(image_id):
lock.release()
self._send_error_json(HTTPStatus.SERVICE_UNAVAILABLE, "too many parallel writes")
return
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:
self._concurrency.release_write(image_id)
lock.release()
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:
lock = self._concurrency.get_image_lock(image_id)
lock.acquire()
if not self._concurrency.acquire_write(image_id):
lock.release()
self._send_error_json(HTTPStatus.SERVICE_UNAVAILABLE, "too many parallel writes")
return
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
backend = create_backend(cfg)
try:
nbd_backend: NbdBackend = backend # type: ignore[assignment]
bytes_written = nbd_backend.write_range(self.rfile, start_off, content_length)
if flush:
nbd_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))
finally:
backend.close()
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:
self._concurrency.release_write(image_id)
lock.release()
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:
lock = self._concurrency.get_image_lock(image_id)
if not lock.acquire(blocking=False):
self._send_error_json(HTTPStatus.CONFLICT, "image busy")
return
start = now_s()
try:
logging.info("EXTENTS start image_id=%s context=%s", image_id, context)
backend = create_backend(cfg)
try:
if context == "dirty":
nbd_backend: NbdBackend = backend # type: ignore[assignment]
export_bitmap = nbd_backend.export_bitmap
if not export_bitmap:
allocation = nbd_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 = nbd_backend.get_dirty_extents(dirty_bitmap_ctx)
if is_fallback_dirty_response(extents):
allocation = nbd_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)
finally:
backend.close()
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:
lock.release()
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:
lock = self._concurrency.get_image_lock(image_id)
if not lock.acquire(blocking=False):
self._send_error_json(HTTPStatus.CONFLICT, "image busy")
return
start = now_s()
try:
logging.info("FLUSH start image_id=%s", image_id)
backend = create_backend(cfg)
try:
backend.flush()
self._send_json(HTTPStatus.OK, {"ok": True})
finally:
backend.close()
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:
lock.release()
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:
lock = self._concurrency.get_image_lock(image_id)
if not lock.acquire(blocking=False):
self._send_error_json(HTTPStatus.CONFLICT, "image busy")
return
if not self._concurrency.acquire_write(image_id):
lock.release()
self._send_error_json(HTTPStatus.SERVICE_UNAVAILABLE, "too many parallel writes")
return
start = now_s()
try:
logging.info(
"PATCH zero start image_id=%s offset=%d size=%d flush=%s",
image_id, offset, size, flush,
)
backend = create_backend(cfg)
try:
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))
finally:
backend.close()
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:
self._concurrency.release_write(image_id)
lock.release()
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:
lock = self._concurrency.get_image_lock(image_id)
if not lock.acquire(blocking=False):
self._send_error_json(HTTPStatus.CONFLICT, "image busy")
return
if not self._concurrency.acquire_write(image_id):
lock.release()
self._send_error_json(HTTPStatus.SERVICE_UNAVAILABLE, "too many parallel writes")
return
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,
)
backend = create_backend(cfg)
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))
finally:
backend.close()
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:
self._concurrency.release_write(image_id)
lock.release()
dur = now_s() - start
logging.info(
"PATCH range end image_id=%s bytes=%d duration_s=%.3f",
image_id, bytes_written, dur,
)