blob: 8654aa5dad6c2192eaa07e47b18ae009d73711ec [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 NBD block status entry parsing.
"""
import os
import shutil
import subprocess
import tempfile
import unittest
from imageserver.backends.nbd import NbdConnection, _entries_to_pairs
from imageserver.util import is_fallback_dirty_response
class TestRealLibnbdBlockStatus(unittest.TestCase):
"""
Creates a 1 MiB qcow2 with a persistent dirty bitmap, dirties two
64 KiB regions, exports it via qemu-nbd --bitmap, and asserts both the
raw callback entry format and the parsed extents. Fails loudly if a
new libnbd version changes the block status response.
"""
REAL_BITMAP = "bm0"
KB64 = 65536
REAL_SIZE = 16 * KB64 # 1 MiB
@classmethod
def setUpClass(cls):
cls._tmp = tempfile.mkdtemp(prefix="nbd_entries_test_")
cls._img = os.path.join(cls._tmp, "img.qcow2")
cls._sock = os.path.join(cls._tmp, "nbd.sock")
cls._proc = None
def run(*cmd):
subprocess.run(cmd, check=True, capture_output=True)
try:
run("qemu-img", "create", "-f", "qcow2", cls._img, str(cls.REAL_SIZE))
# allocate 0-192 KiB before the bitmap exists (clean, allocated)
run("qemu-io", "-f", "qcow2", "-c", "write -P 0xaa 0 192k", cls._img)
run("qemu-img", "bitmap", "--add", cls._img, cls.REAL_BITMAP)
# dirty two 64 KiB regions while the bitmap is recording
run("qemu-io", "-f", "qcow2", "-c", "write -P 0xbb 128k 64k", cls._img)
run("qemu-io", "-f", "qcow2", "-c", "write -P 0xcc 512k 64k", cls._img)
cls._proc = subprocess.Popen(
[
"qemu-nbd",
"--socket", cls._sock,
"--format", "qcow2",
"--persistent",
"--shared=0",
"--read-only",
f"--bitmap={cls.REAL_BITMAP}",
cls._img,
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
from .test_base import _wait_for_nbd_socket
_wait_for_nbd_socket(cls._sock)
except BaseException:
cls._teardown()
raise
@classmethod
def tearDownClass(cls):
cls._teardown()
@classmethod
def _teardown(cls):
if cls._proc is not None:
for pipe in (cls._proc.stdout, cls._proc.stderr):
if pipe:
pipe.close()
cls._proc.terminate()
try:
cls._proc.wait(timeout=5)
except subprocess.TimeoutExpired:
cls._proc.kill()
cls._proc.wait(timeout=5)
cls._proc = None
shutil.rmtree(cls._tmp, ignore_errors=True)
def _connect(self):
return NbdConnection(
self._sock,
None,
need_block_status=True,
extra_meta_contexts=[f"qemu:dirty-bitmap:{self.REAL_BITMAP}"],
)
def test_raw_entries_format_is_known(self):
"""The raw callback entries must be flat ints or (length, flags) pairs."""
with self._connect() as conn:
size = conn.size()
captured = []
def cb(*args, **kwargs):
self.assertGreaterEqual(len(args), 3)
captured.append((args[0], list(args[2])))
return 0
fn = getattr(
conn._nbd,
"block_status_64",
getattr(conn._nbd, "block_status", None),
)
self.assertIsNotNone(
fn, "libnbd no longer exposes block_status/block_status_64"
)
fn(size, 0, cb)
self.assertTrue(captured, "block status delivered no callbacks")
contexts = {ctx for ctx, _ in captured}
self.assertIn("base:allocation", contexts)
self.assertIn(f"qemu:dirty-bitmap:{self.REAL_BITMAP}", contexts)
for ctx, entries in captured:
self.assertTrue(entries, f"empty entries for context {ctx}")
if isinstance(entries[0], (tuple, list)):
for item in entries:
self.assertIsInstance(item, (tuple, list))
self.assertEqual(len(item), 2)
self.assertIsInstance(item[0], int)
self.assertIsInstance(item[1], int)
else:
self.assertTrue(all(isinstance(v, int) for v in entries))
self.assertEqual(len(entries) % 2, 0)
# entries must parse and tile part of the queried range
pairs = _entries_to_pairs(entries)
self.assertTrue(pairs)
covered = sum(length for length, _ in pairs)
self.assertGreater(covered, 0)
self.assertLessEqual(covered, size)
def test_allocation_extents_tile_image(self):
with self._connect() as conn:
size = conn.size()
extents = conn.get_allocation_extents()
self.assertEqual(size, self.REAL_SIZE)
pos = 0
for e in extents:
self.assertEqual(e["start"], pos)
self.assertGreater(e["length"], 0)
pos += e["length"]
self.assertEqual(pos, size)
self.assertEqual(
extents,
[
{"start": 0, "length": 3 * self.KB64, "zero": False},
{"start": 3 * self.KB64, "length": 5 * self.KB64, "zero": True},
{"start": 8 * self.KB64, "length": self.KB64, "zero": False},
{"start": 9 * self.KB64, "length": 7 * self.KB64, "zero": True},
],
)
def test_dirty_extents_match_writes(self):
with self._connect() as conn:
extents = conn.get_extents_dirty_and_zero(
f"qemu:dirty-bitmap:{self.REAL_BITMAP}"
)
self.assertEqual(
extents,
[
{"start": 0, "length": 2 * self.KB64, "dirty": False, "zero": False},
{"start": 2 * self.KB64, "length": self.KB64, "dirty": True, "zero": False},
{"start": 3 * self.KB64, "length": 5 * self.KB64, "dirty": False, "zero": True},
{"start": 8 * self.KB64, "length": self.KB64, "dirty": True, "zero": False},
{"start": 9 * self.KB64, "length": 7 * self.KB64, "dirty": False, "zero": True},
],
)
self.assertFalse(is_fallback_dirty_response(extents))
if __name__ == "__main__":
unittest.main()