|
| 1 | +# Licensed to the Apache Software Foundation (ASF) under one |
| 2 | +# or more contributor license agreements. See the NOTICE file |
| 3 | +# distributed with this work for additional information |
| 4 | +# regarding copyright ownership. The ASF licenses this file |
| 5 | +# to you under the Apache License, Version 2.0 (the |
| 6 | +# "License"); you may not use this file except in compliance |
| 7 | +# with the License. You may obtain a copy of the License at |
| 8 | +# |
| 9 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | +# |
| 11 | +# Unless required by applicable law or agreed to in writing, |
| 12 | +# software distributed under the License is distributed on an |
| 13 | +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| 14 | +# KIND, either express or implied. See the License for the |
| 15 | +# specific language governing permissions and limitations |
| 16 | +# under the License. |
| 17 | + |
| 18 | +""" |
| 19 | +Tests for NBD block status entry parsing. |
| 20 | +""" |
| 21 | + |
| 22 | +import os |
| 23 | +import shutil |
| 24 | +import subprocess |
| 25 | +import tempfile |
| 26 | +import unittest |
| 27 | + |
| 28 | +from imageserver.backends.nbd import NbdConnection, _entries_to_pairs |
| 29 | +from imageserver.util import is_fallback_dirty_response |
| 30 | + |
| 31 | +class TestRealLibnbdBlockStatus(unittest.TestCase): |
| 32 | + """ |
| 33 | + Creates a 1 MiB qcow2 with a persistent dirty bitmap, dirties two |
| 34 | + 64 KiB regions, exports it via qemu-nbd --bitmap, and asserts both the |
| 35 | + raw callback entry format and the parsed extents. Fails loudly if a |
| 36 | + new libnbd version changes the block status response. |
| 37 | + """ |
| 38 | + |
| 39 | + REAL_BITMAP = "bm0" |
| 40 | + KB64 = 65536 |
| 41 | + REAL_SIZE = 16 * KB64 # 1 MiB |
| 42 | + |
| 43 | + @classmethod |
| 44 | + def setUpClass(cls): |
| 45 | + cls._tmp = tempfile.mkdtemp(prefix="nbd_entries_test_") |
| 46 | + cls._img = os.path.join(cls._tmp, "img.qcow2") |
| 47 | + cls._sock = os.path.join(cls._tmp, "nbd.sock") |
| 48 | + cls._proc = None |
| 49 | + |
| 50 | + def run(*cmd): |
| 51 | + subprocess.run(cmd, check=True, capture_output=True) |
| 52 | + |
| 53 | + try: |
| 54 | + run("qemu-img", "create", "-f", "qcow2", cls._img, str(cls.REAL_SIZE)) |
| 55 | + # allocate 0-192 KiB before the bitmap exists (clean, allocated) |
| 56 | + run("qemu-io", "-f", "qcow2", "-c", "write -P 0xaa 0 192k", cls._img) |
| 57 | + run("qemu-img", "bitmap", "--add", cls._img, cls.REAL_BITMAP) |
| 58 | + # dirty two 64 KiB regions while the bitmap is recording |
| 59 | + run("qemu-io", "-f", "qcow2", "-c", "write -P 0xbb 128k 64k", cls._img) |
| 60 | + run("qemu-io", "-f", "qcow2", "-c", "write -P 0xcc 512k 64k", cls._img) |
| 61 | + |
| 62 | + cls._proc = subprocess.Popen( |
| 63 | + [ |
| 64 | + "qemu-nbd", |
| 65 | + "--socket", cls._sock, |
| 66 | + "--format", "qcow2", |
| 67 | + "--persistent", |
| 68 | + "--shared=0", |
| 69 | + "--read-only", |
| 70 | + f"--bitmap={cls.REAL_BITMAP}", |
| 71 | + cls._img, |
| 72 | + ], |
| 73 | + stdout=subprocess.PIPE, |
| 74 | + stderr=subprocess.PIPE, |
| 75 | + ) |
| 76 | + from .test_base import _wait_for_nbd_socket |
| 77 | + |
| 78 | + _wait_for_nbd_socket(cls._sock) |
| 79 | + except BaseException: |
| 80 | + cls._teardown() |
| 81 | + raise |
| 82 | + |
| 83 | + @classmethod |
| 84 | + def tearDownClass(cls): |
| 85 | + cls._teardown() |
| 86 | + |
| 87 | + @classmethod |
| 88 | + def _teardown(cls): |
| 89 | + if cls._proc is not None: |
| 90 | + for pipe in (cls._proc.stdout, cls._proc.stderr): |
| 91 | + if pipe: |
| 92 | + pipe.close() |
| 93 | + cls._proc.terminate() |
| 94 | + try: |
| 95 | + cls._proc.wait(timeout=5) |
| 96 | + except subprocess.TimeoutExpired: |
| 97 | + cls._proc.kill() |
| 98 | + cls._proc.wait(timeout=5) |
| 99 | + cls._proc = None |
| 100 | + shutil.rmtree(cls._tmp, ignore_errors=True) |
| 101 | + |
| 102 | + def _connect(self): |
| 103 | + return NbdConnection( |
| 104 | + self._sock, |
| 105 | + None, |
| 106 | + need_block_status=True, |
| 107 | + extra_meta_contexts=[f"qemu:dirty-bitmap:{self.REAL_BITMAP}"], |
| 108 | + ) |
| 109 | + |
| 110 | + def test_raw_entries_format_is_known(self): |
| 111 | + """The raw callback entries must be flat ints or (length, flags) pairs.""" |
| 112 | + with self._connect() as conn: |
| 113 | + size = conn.size() |
| 114 | + captured = [] |
| 115 | + |
| 116 | + def cb(*args, **kwargs): |
| 117 | + self.assertGreaterEqual(len(args), 3) |
| 118 | + captured.append((args[0], list(args[2]))) |
| 119 | + return 0 |
| 120 | + |
| 121 | + fn = getattr( |
| 122 | + conn._nbd, |
| 123 | + "block_status_64", |
| 124 | + getattr(conn._nbd, "block_status", None), |
| 125 | + ) |
| 126 | + self.assertIsNotNone( |
| 127 | + fn, "libnbd no longer exposes block_status/block_status_64" |
| 128 | + ) |
| 129 | + fn(size, 0, cb) |
| 130 | + |
| 131 | + self.assertTrue(captured, "block status delivered no callbacks") |
| 132 | + contexts = {ctx for ctx, _ in captured} |
| 133 | + self.assertIn("base:allocation", contexts) |
| 134 | + self.assertIn(f"qemu:dirty-bitmap:{self.REAL_BITMAP}", contexts) |
| 135 | + |
| 136 | + for ctx, entries in captured: |
| 137 | + self.assertTrue(entries, f"empty entries for context {ctx}") |
| 138 | + if isinstance(entries[0], (tuple, list)): |
| 139 | + for item in entries: |
| 140 | + self.assertIsInstance(item, (tuple, list)) |
| 141 | + self.assertEqual(len(item), 2) |
| 142 | + self.assertIsInstance(item[0], int) |
| 143 | + self.assertIsInstance(item[1], int) |
| 144 | + else: |
| 145 | + self.assertTrue(all(isinstance(v, int) for v in entries)) |
| 146 | + self.assertEqual(len(entries) % 2, 0) |
| 147 | + # entries must parse and tile part of the queried range |
| 148 | + pairs = _entries_to_pairs(entries) |
| 149 | + self.assertTrue(pairs) |
| 150 | + covered = sum(length for length, _ in pairs) |
| 151 | + self.assertGreater(covered, 0) |
| 152 | + self.assertLessEqual(covered, size) |
| 153 | + |
| 154 | + def test_allocation_extents_tile_image(self): |
| 155 | + with self._connect() as conn: |
| 156 | + size = conn.size() |
| 157 | + extents = conn.get_allocation_extents() |
| 158 | + self.assertEqual(size, self.REAL_SIZE) |
| 159 | + pos = 0 |
| 160 | + for e in extents: |
| 161 | + self.assertEqual(e["start"], pos) |
| 162 | + self.assertGreater(e["length"], 0) |
| 163 | + pos += e["length"] |
| 164 | + self.assertEqual(pos, size) |
| 165 | + self.assertEqual( |
| 166 | + extents, |
| 167 | + [ |
| 168 | + {"start": 0, "length": 3 * self.KB64, "zero": False}, |
| 169 | + {"start": 3 * self.KB64, "length": 5 * self.KB64, "zero": True}, |
| 170 | + {"start": 8 * self.KB64, "length": self.KB64, "zero": False}, |
| 171 | + {"start": 9 * self.KB64, "length": 7 * self.KB64, "zero": True}, |
| 172 | + ], |
| 173 | + ) |
| 174 | + |
| 175 | + def test_dirty_extents_match_writes(self): |
| 176 | + with self._connect() as conn: |
| 177 | + extents = conn.get_extents_dirty_and_zero( |
| 178 | + f"qemu:dirty-bitmap:{self.REAL_BITMAP}" |
| 179 | + ) |
| 180 | + self.assertEqual( |
| 181 | + extents, |
| 182 | + [ |
| 183 | + {"start": 0, "length": 2 * self.KB64, "dirty": False, "zero": False}, |
| 184 | + {"start": 2 * self.KB64, "length": self.KB64, "dirty": True, "zero": False}, |
| 185 | + {"start": 3 * self.KB64, "length": 5 * self.KB64, "dirty": False, "zero": True}, |
| 186 | + {"start": 8 * self.KB64, "length": self.KB64, "dirty": True, "zero": False}, |
| 187 | + {"start": 9 * self.KB64, "length": 7 * self.KB64, "dirty": False, "zero": True}, |
| 188 | + ], |
| 189 | + ) |
| 190 | + self.assertFalse(is_fallback_dirty_response(extents)) |
| 191 | + |
| 192 | + |
| 193 | +if __name__ == "__main__": |
| 194 | + unittest.main() |
0 commit comments