blob: da120ae6bad5bd53dc99c71ff67e7f5f956d95e3 [file]
# 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.
"""Tests for HTTP operations against an NBD-backend transfer (real qemu-nbd)."""
import json
import os
import subprocess
import unittest
import uuid
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from imageserver.constants import MAX_PARALLEL_READS, MAX_PARALLEL_WRITES
from .test_base import (
IMAGE_SIZE,
ImageServerTestCase,
get_tmp_dir,
http_get,
http_options,
http_patch,
http_post,
http_put,
make_nbd_transfer,
make_nbd_transfer_existing_disk,
randbytes,
shutdown_image_server,
)
class NbdBackendTestCase(ImageServerTestCase):
"""Base that creates an NBD-backend transfer per test."""
def setUp(self):
self._tid, self._url, self._nbd, self._cleanup = make_nbd_transfer()
def tearDown(self):
self._cleanup()
class TestOptions(NbdBackendTestCase):
def test_options_returns_extents_feature(self):
resp = http_options(self._url)
self.assertEqual(resp.status, 200)
body = json.loads(resp.read())
self.assertIn("extents", body["features"])
def test_options_includes_patch_method(self):
resp = http_options(self._url)
methods = resp.getheader("Access-Control-Allow-Methods")
self.assertIn("PATCH", methods)
def test_options_has_capabilities(self):
resp = http_options(self._url)
body = json.loads(resp.read())
self.assertGreaterEqual(body["max_readers"], 1)
self.assertGreaterEqual(body["max_writers"], 1)
class TestGetFull(NbdBackendTestCase):
def test_get_full_returns_image_data(self):
with open(self._nbd.image_path, "rb") as f:
expected = f.read()
resp = http_get(self._url)
data = resp.read()
self.assertEqual(resp.status, 200)
self.assertEqual(len(data), len(expected))
self.assertEqual(data, expected)
def test_get_full_content_length(self):
resp = http_get(self._url)
resp.read()
self.assertEqual(int(resp.getheader("Content-Length")), IMAGE_SIZE)
class TestGetRange(NbdBackendTestCase):
def test_get_range_partial(self):
test_data = randbytes(50, IMAGE_SIZE)
http_put(self._url, test_data)
resp = http_get(self._url, headers={"Range": "bytes=100-299"})
self.assertEqual(resp.status, 206)
self.assertEqual(resp.read(), test_data[100:300])
def test_get_range_content_range_header(self):
resp = http_get(self._url, headers={"Range": "bytes=0-99"})
self.assertEqual(resp.status, 206)
resp.read()
self.assertEqual(resp.getheader("Content-Range"), f"bytes 0-99/{IMAGE_SIZE}")
def test_get_range_suffix(self):
test_data = randbytes(51, IMAGE_SIZE)
http_put(self._url, test_data)
resp = http_get(self._url, headers={"Range": "bytes=-100"})
self.assertEqual(resp.status, 206)
self.assertEqual(resp.read(), test_data[-100:])
def test_get_range_unsatisfiable(self):
with self.assertRaises(urllib.error.HTTPError) as ctx:
http_get(self._url, headers={"Range": f"bytes={IMAGE_SIZE + 100}-{IMAGE_SIZE + 200}"})
self.assertEqual(ctx.exception.code, 416)
class TestPutFull(NbdBackendTestCase):
def test_put_full_upload(self):
new_data = randbytes(60, IMAGE_SIZE)
resp = http_put(self._url, new_data)
body = json.loads(resp.read())
self.assertEqual(resp.status, 200)
self.assertTrue(body["ok"])
self.assertEqual(body["bytes_written"], IMAGE_SIZE)
resp2 = http_get(self._url)
self.assertEqual(resp2.read(), new_data)
def test_put_with_flush(self):
new_data = randbytes(61, IMAGE_SIZE)
resp = http_put(f"{self._url}?flush=y", new_data)
body = json.loads(resp.read())
self.assertTrue(body["ok"])
self.assertTrue(body["flushed"])
class TestPutRange(NbdBackendTestCase):
def test_put_content_range(self):
base_data = randbytes(70, IMAGE_SIZE)
http_put(self._url, base_data)
patch_data = b"\xAB" * 512
resp = http_put(self._url, patch_data, headers={
"Content-Range": "bytes 0-511/*",
"Content-Length": str(len(patch_data)),
})
body = json.loads(resp.read())
self.assertEqual(resp.status, 200)
self.assertTrue(body["ok"])
self.assertEqual(body["bytes_written"], 512)
resp2 = http_get(self._url, headers={"Range": "bytes=0-511"})
self.assertEqual(resp2.read(), patch_data)
resp3 = http_get(self._url, headers={"Range": "bytes=512-1023"})
self.assertEqual(resp3.read(), base_data[512:1024])
def test_put_content_range_with_flush(self):
base_data = b"\x00" * IMAGE_SIZE
http_put(self._url, base_data)
patch_data = b"\xFF" * 256
resp = http_put(f"{self._url}?flush=y", patch_data, headers={
"Content-Range": "bytes 1024-1279/*",
"Content-Length": str(len(patch_data)),
})
body = json.loads(resp.read())
self.assertTrue(body["ok"])
self.assertTrue(body["flushed"])
class TestPatchRange(NbdBackendTestCase):
def test_patch_binary_range(self):
base_data = randbytes(80, IMAGE_SIZE)
http_put(self._url, base_data)
patch_data = b"\xCD" * 1024
resp = http_patch(self._url, patch_data, headers={
"Range": "bytes=2048-3071",
"Content-Type": "application/octet-stream",
"Content-Length": str(len(patch_data)),
})
body = json.loads(resp.read())
self.assertEqual(resp.status, 200)
self.assertTrue(body["ok"])
self.assertEqual(body["bytes_written"], 1024)
resp2 = http_get(self._url, headers={"Range": "bytes=2048-3071"})
self.assertEqual(resp2.read(), patch_data)
def test_patch_multiple_ranges_preserves_unwritten(self):
base_data = randbytes(81, IMAGE_SIZE)
http_put(self._url, base_data)
patch1 = b"\x11" * 256
http_patch(self._url, patch1, headers={
"Range": "bytes=0-255",
"Content-Type": "application/octet-stream",
"Content-Length": "256",
})
patch2 = b"\x22" * 256
http_patch(self._url, patch2, headers={
"Range": "bytes=512-767",
"Content-Type": "application/octet-stream",
"Content-Length": "256",
})
resp = http_get(self._url, headers={"Range": "bytes=0-767"})
got = resp.read()
self.assertEqual(got[:256], patch1)
self.assertEqual(got[256:512], base_data[256:512])
self.assertEqual(got[512:768], patch2)
class TestPatchZero(NbdBackendTestCase):
def test_patch_zero(self):
data = randbytes(90, IMAGE_SIZE)
http_put(self._url, data)
payload = json.dumps({"op": "zero", "size": 4096, "offset": 0}).encode()
resp = http_patch(self._url, payload, headers={
"Content-Type": "application/json",
"Content-Length": str(len(payload)),
})
body = json.loads(resp.read())
self.assertEqual(resp.status, 200)
self.assertTrue(body["ok"])
resp2 = http_get(self._url, headers={"Range": "bytes=0-4095"})
self.assertEqual(resp2.read(), b"\x00" * 4096)
def test_patch_zero_with_flush(self):
data = b"\xFF" * IMAGE_SIZE
http_put(self._url, data)
payload = json.dumps({"op": "zero", "size": 512, "offset": 1024, "flush": True}).encode()
resp = http_patch(self._url, payload, headers={
"Content-Type": "application/json",
"Content-Length": str(len(payload)),
})
body = json.loads(resp.read())
self.assertTrue(body["ok"])
resp2 = http_get(self._url, headers={"Range": "bytes=1024-1535"})
self.assertEqual(resp2.read(), b"\x00" * 512)
def test_patch_zero_preserves_neighbors(self):
data = randbytes(91, IMAGE_SIZE)
http_put(self._url, data)
payload = json.dumps({"op": "zero", "size": 256, "offset": 512}).encode()
http_patch(self._url, payload, headers={
"Content-Type": "application/json",
"Content-Length": str(len(payload)),
})
resp = http_get(self._url, headers={"Range": "bytes=0-1023"})
got = resp.read()
self.assertEqual(got[:512], data[:512])
self.assertEqual(got[512:768], b"\x00" * 256)
self.assertEqual(got[768:1024], data[768:1024])
class TestPatchFlush(NbdBackendTestCase):
def test_patch_flush_op(self):
payload = json.dumps({"op": "flush"}).encode()
resp = http_patch(self._url, payload, headers={
"Content-Type": "application/json",
"Content-Length": str(len(payload)),
})
body = json.loads(resp.read())
self.assertEqual(resp.status, 200)
self.assertTrue(body["ok"])
class TestPostFlush(NbdBackendTestCase):
def test_post_flush(self):
resp = http_post(f"{self._url}/flush")
body = json.loads(resp.read())
self.assertEqual(resp.status, 200)
self.assertTrue(body["ok"])
class TestExtents(NbdBackendTestCase):
def test_get_allocation_extents(self):
resp = http_get(f"{self._url}/extents")
self.assertEqual(resp.status, 200)
extents = json.loads(resp.read())
self.assertIsInstance(extents, list)
self.assertGreaterEqual(len(extents), 1)
for ext in extents:
self.assertIn("start", ext)
self.assertIn("length", ext)
self.assertIn("zero", ext)
def test_extents_cover_full_image(self):
resp = http_get(f"{self._url}/extents")
extents = json.loads(resp.read())
total = sum(e["length"] for e in extents)
self.assertEqual(total, IMAGE_SIZE)
def test_extents_dirty_context_without_bitmap(self):
resp = http_get(f"{self._url}/extents?context=dirty")
self.assertEqual(resp.status, 200)
extents = json.loads(resp.read())
self.assertIsInstance(extents, list)
self.assertGreaterEqual(len(extents), 1)
for ext in extents:
self.assertIn("dirty", ext)
self.assertTrue(ext["dirty"])
def test_extents_after_write_and_zero(self):
http_put(self._url, randbytes(95, IMAGE_SIZE))
payload = json.dumps({"op": "zero", "size": 4096, "offset": 0}).encode()
http_patch(self._url, payload, headers={
"Content-Type": "application/json",
"Content-Length": str(len(payload)),
})
resp = http_get(f"{self._url}/extents")
extents = json.loads(resp.read())
self.assertGreaterEqual(len(extents), 1)
total = sum(e["length"] for e in extents)
self.assertEqual(total, IMAGE_SIZE)
def _allocated_subranges(extents, granularity):
"""Split each non-hole extent (zero=False) into [start, end] inclusive byte ranges."""
out = []
for ext in extents:
if ext.get("zero"):
continue
start = int(ext["start"])
length = int(ext["length"])
pos = start
end_abs = start + length
while pos < end_abs:
chunk_end = min(pos + granularity, end_abs)
out.append((pos, chunk_end - 1))
pos = chunk_end
return out
def _qemu_img_virtual_size(path: str) -> int:
"""Return virtual size in bytes (requires ``qemu-img`` on PATH)."""
# stdout=PIPE + universal_newlines: Python 3.6 compatible (no capture_output/text).
cp = subprocess.run(
["qemu-img", "info", "--output=json", path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
check=True,
)
return int(json.loads(cp.stdout)["virtual-size"])
def _http_error_detail(exc: urllib.error.HTTPError) -> str:
"""Build a readable message from an ``HTTPError`` (status, url, JSON/text body)."""
parts = ["HTTP %s %r" % (exc.code, exc.reason), "url=%r" % getattr(exc, "url", "")]
try:
if exc.fp is not None:
raw = exc.fp.read()
if raw:
text = raw.decode("utf-8", errors="replace")
parts.append("response_body=%r" % (text,))
except Exception as read_err:
parts.append("read_body_error=%r" % (read_err,))
return "; ".join(parts)
def _http_get_checked(
url,
headers=None,
expected_status=200,
label="GET",
):
"""
Like ``http_get`` but raises ``AssertionError`` with ``_http_error_detail`` on failure.
"""
try:
resp = http_get(url, headers=headers)
except urllib.error.HTTPError as e:
raise AssertionError(
"%s failed for %r: %s" % (label, url, _http_error_detail(e))
) from e
if resp.status != expected_status:
body = resp.read()
raise AssertionError(
"%s %r: expected HTTP %s, got %s; body=%r"
% (label, url, expected_status, resp.status, body)
)
return resp
def _http_put_checked(url, data, headers, label="PUT"):
try:
resp = http_put(url, data, headers=headers)
except urllib.error.HTTPError as e:
raise AssertionError(
"%s failed for %r: %s" % (label, url, _http_error_detail(e))
) from e
body = resp.read()
if resp.status != 200:
raise AssertionError(
"%s %r: expected HTTP 200, got %s; body=%r"
% (label, url, resp.status, body)
)
return resp, body
def _http_post_checked(url, data=b"", headers=None, label="POST"):
try:
resp = http_post(url, data=data, headers=headers)
except urllib.error.HTTPError as e:
raise AssertionError(
"%s failed for %r: %s" % (label, url, _http_error_detail(e))
) from e
body = resp.read()
if resp.status != 200:
raise AssertionError(
"%s %r: expected HTTP 200, got %s; body=%r"
% (label, url, resp.status, body)
)
return resp, body
class TestQcow2ExtentsParallelReads(ImageServerTestCase):
"""
Optional integration tests: export a user-supplied qcow2 via qemu-nbd, fetch
allocation extents, parallel range GETs over allocated regions, and (second
test) per-range GET-then-PUT pipeline with ``min(MAX_PARALLEL_READS,
MAX_PARALLEL_WRITES)`` workers.
Requires ``qemu-img`` and ``qemu-nbd`` on PATH.
Set IMAGESERVER_TEST_QCOW2 to the absolute path of a qcow2 file.
Optional: IMAGESERVER_TEST_QCOW2_READ_GRANULARITY — byte step (default 4 MiB).
"""
def setUp(self):
super().setUp()
self._qcow2_path = os.environ.get("IMAGESERVER_TEST_QCOW2", "").strip()
if not self._qcow2_path or not os.path.isfile(self._qcow2_path):
self.skipTest(
"Set IMAGESERVER_TEST_QCOW2 to an existing qcow2 path to run this test"
)
raw_g = os.environ.get("IMAGESERVER_TEST_QCOW2_READ_GRANULARITY", "").strip()
self._read_granularity = int(raw_g) if raw_g else 4 * 1024 * 1024
if self._read_granularity <= 0:
self.skipTest("IMAGESERVER_TEST_QCOW2_READ_GRANULARITY must be positive")
def test_parallel_range_reads_allocated_extents(self):
_, url, _, cleanup = make_nbd_transfer_existing_disk(
self._qcow2_path, "qcow2"
)
try:
resp = _http_get_checked(
"%s/extents" % (url,),
expected_status=200,
label="GET /extents",
)
extents = json.loads(resp.read())
self.assertIsInstance(extents, list)
ranges = _allocated_subranges(extents, self._read_granularity)
if not ranges:
self.skipTest("no allocated extents (all holes/zero) in qcow2")
def fetch(span):
start_b, end_b = span
range_hdr = "bytes=%s-%s" % (start_b, end_b)
r = _http_get_checked(
url,
headers={"Range": range_hdr},
expected_status=206,
label="Range GET %s" % (range_hdr,),
)
data = r.read()
expected_len = end_b - start_b + 1
if len(data) != expected_len:
raise AssertionError(
"Range GET %s: got %d bytes, expected %d (url=%r)"
% (range_hdr, len(data), expected_len, url)
)
with ThreadPoolExecutor(max_workers=MAX_PARALLEL_READS) as pool:
pool.map(fetch, ranges)
finally:
cleanup()
def test_parallel_reads_then_put_range_copy_matches_source(self):
"""
Create an empty qcow2 with the same virtual size as the source, copy every
allocated range using one worker pool: for each span, Range GET from src
then Content-Range PUT to dest.
Worker count is ``min(MAX_PARALLEL_READS, MAX_PARALLEL_WRITES)`` so each
worker holds at most one chunk.
"""
src_path = self._qcow2_path
try:
vsize = _qemu_img_virtual_size(src_path)
except (FileNotFoundError, subprocess.CalledProcessError, KeyError, json.JSONDecodeError, TypeError, ValueError) as e:
self.skipTest(f"qemu-img info failed: {e}")
tmp = get_tmp_dir()
dest_path = os.path.join(tmp, f"qcow2_copy_{uuid.uuid4().hex[:8]}.qcow2")
try:
subprocess.run(
["qemu-img", "create", "-f", "qcow2", dest_path, str(vsize)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
check=True,
)
except (FileNotFoundError, subprocess.CalledProcessError) as e:
self.skipTest(f"qemu-img create failed: {e}")
_, src_url, _, cleanup_src = make_nbd_transfer_existing_disk(
src_path, "qcow2"
)
_, dest_url, _, cleanup_dest = make_nbd_transfer_existing_disk(
dest_path, "qcow2"
)
try:
resp = _http_get_checked(
"%s/extents" % (src_url,),
expected_status=200,
label="GET src /extents",
)
extents = json.loads(resp.read())
ranges = _allocated_subranges(extents, self._read_granularity)
if not ranges:
self.skipTest("no allocated extents (all holes/zero) in qcow2")
transfer_workers = max(
1, min(MAX_PARALLEL_READS, MAX_PARALLEL_WRITES)
)
def transfer_span(span):
start_b, end_b = span
range_hdr = "bytes=%s-%s" % (start_b, end_b)
r = _http_get_checked(
src_url,
headers={"Range": range_hdr},
expected_status=206,
label="Range GET src %s" % (range_hdr,),
)
data = r.read()
expected_len = end_b - start_b + 1
if len(data) != expected_len:
raise AssertionError(
"Range GET src %s: got %d bytes, expected %d (url=%r)"
% (range_hdr, len(data), expected_len, src_url)
)
end_inclusive = start_b + len(data) - 1
cr = "bytes %s-%s/*" % (start_b, end_inclusive)
_put_resp, put_body = _http_put_checked(
dest_url,
data,
headers={
"Content-Range": cr,
"Content-Length": str(len(data)),
},
label="PUT dest %s" % (cr,),
)
try:
body = json.loads(put_body)
except ValueError:
raise AssertionError(
"PUT dest %s: invalid JSON body=%r (url=%r)"
% (cr, put_body, dest_url)
)
if not body.get("ok"):
raise AssertionError(
"PUT dest %s: JSON ok=false, full=%r (url=%r)"
% (cr, body, dest_url)
)
if body.get("bytes_written") != len(data):
raise AssertionError(
"PUT dest %s: bytes_written=%r expected %d (url=%r)"
% (cr, body.get("bytes_written"), len(data), dest_url)
)
with ThreadPoolExecutor(max_workers=transfer_workers) as pool:
pool.map(transfer_span, ranges)
_flush, flush_body = _http_post_checked(
"%s/flush" % (dest_url,),
label="POST dest /flush",
)
try:
flush_json = json.loads(flush_body)
except ValueError:
raise AssertionError(
"POST dest /flush: invalid JSON body=%r (url=%r)"
% (flush_body, dest_url)
)
if not flush_json.get("ok"):
raise AssertionError(
"POST dest /flush: ok=false, full=%r (url=%r)"
% (flush_json, dest_url)
)
finally:
cleanup_dest()
cleanup_src()
try:
cmp = subprocess.run(
["qemu-img", "compare", src_path, dest_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
self.assertEqual(
cmp.returncode,
0,
"qemu-img compare %r vs %r failed (rc=%s): stderr=%r stdout=%r"
% (
src_path,
dest_path,
cmp.returncode,
cmp.stderr,
cmp.stdout,
),
)
finally:
try:
os.unlink(dest_path)
except FileNotFoundError:
pass
class TestErrorCases(NbdBackendTestCase):
def test_patch_unsupported_op(self):
payload = json.dumps({"op": "invalid"}).encode()
with self.assertRaises(urllib.error.HTTPError) as ctx:
http_patch(self._url, payload, headers={
"Content-Type": "application/json",
"Content-Length": str(len(payload)),
})
self.assertEqual(ctx.exception.code, 400)
def test_patch_zero_missing_size(self):
payload = json.dumps({"op": "zero", "offset": 0}).encode()
with self.assertRaises(urllib.error.HTTPError) as ctx:
http_patch(self._url, payload, headers={
"Content-Type": "application/json",
"Content-Length": str(len(payload)),
})
self.assertEqual(ctx.exception.code, 400)
def test_put_missing_content_length(self):
import http.client
from urllib.parse import urlparse
parsed = urlparse(self._url)
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=30)
try:
conn.putrequest("PUT", parsed.path)
conn.endheaders()
resp = conn.getresponse()
self.assertEqual(resp.status, 400)
finally:
conn.close()
class TestRoundTrip(NbdBackendTestCase):
def test_write_read_full_roundtrip(self):
payload = randbytes(110, IMAGE_SIZE)
http_put(self._url, payload)
resp = http_get(self._url)
self.assertEqual(resp.read(), payload)
def test_write_read_range_roundtrip(self):
payload = randbytes(111, IMAGE_SIZE)
http_put(self._url, payload)
for start, end in [(0, 255), (1024, 2047), (IMAGE_SIZE - 512, IMAGE_SIZE - 1)]:
resp = http_get(self._url, headers={"Range": f"bytes={start}-{end}"})
self.assertEqual(resp.read(), payload[start:end + 1])
def test_range_write_read_roundtrip(self):
http_put(self._url, b"\x00" * IMAGE_SIZE)
chunk = randbytes(112, 4096)
http_put(self._url, chunk, headers={
"Content-Range": "bytes 8192-12287/*",
"Content-Length": str(len(chunk)),
})
resp = http_get(self._url, headers={"Range": "bytes=8192-12287"})
self.assertEqual(resp.read(), chunk)
resp2 = http_get(self._url, headers={"Range": "bytes=0-4095"})
self.assertEqual(resp2.read(), b"\x00" * 4096)
if __name__ == "__main__":
try:
unittest.main()
finally:
shutdown_image_server()