From 620db7508082655dad7052103a0b9bbd97aca406 Mon Sep 17 00:00:00 2001 From: mulhern Date: Wed, 20 Nov 2024 22:11:23 -0500 Subject: [PATCH] Improve post-test checks on metadata Signed-off-by: mulhern --- testlib/check_metadata.py | 449 ++++++++++++++++++++++++++++++++++++++ testlib/infra.py | 37 +--- 2 files changed, 451 insertions(+), 35 deletions(-) create mode 100755 testlib/check_metadata.py diff --git a/testlib/check_metadata.py b/testlib/check_metadata.py new file mode 100755 index 0000000..2414b85 --- /dev/null +++ b/testlib/check_metadata.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 + +# Copyright 2024 Red Hat, Inc. +# +# Licensed 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. + +""" +Inspect Stratis pool-level metadata and produce various kinds of output. +""" + +# isort: STDLIB +import argparse +import json +import os +from collections import defaultdict +from enum import Enum +from uuid import UUID + +SIZE_OF_STRATIS_METADATA_SECTORS = 8192 +SIZE_OF_CRYPT_METADATA_SECTORS = 32768 + + +class Json: # pylint: disable=too-few-public-methods + """ + Keys in the pool-level metadata. + """ + + ALLOCS = "allocs" + BACKSTORE = "backstore" + BLOCKDEV = "blockdev" + CAP = "cap" + CRYPT_META_ALLOCS = "crypt_meta_allocs" + DATA_TIER = "data_tier" + DEVS = "devs" + FEATURES = "features" + INTEGRITY_META_ALLOCS = "integrity_meta_allocs" + LENGTH = "length" + PARENT = "parent" + START = "start" + UUID = "uuid" + + +class Feature: # pylint: disable=too-few-public-methods + """ + Possible feature value. + """ + + ENCRYPTION = "Encryption" + + +class BlockDeviceUses(Enum): + """ + Used for block device allocations + """ + + STRATIS_METADATA = "stratis_metadata" + INTEGRITY_METADATA = "integrity_metadata" + ALLOCATED = "allocated" + UNUSED = "unused" + + def __str__(self): + return self.value + + +class CapDeviceUses(Enum): + """ + Use for cap device allocations. + """ + + ALLOCATED = "allocated" + UNUSED = "unused" + + def __str__(self): + return self.value + + +def _check_overlap(iterable, init): + """ + Check overlap of extents + + :param iterable: the iterable + :param int init: start the check from this offset + + :returns: a list of errors as strings + """ + errors = [] + + current_block = init + for start, (use, length) in sorted(iterable, key=lambda x: x[0]): + if start < current_block: + errors.append( + f"allocation ({start, length}) for {use} overlaps with " + f"previous allocation which extends to {current_block}" + ) + + current_block = start + length + + return errors + + +def _filled(iterable, filler, start_offset): + """ + Return a hash of extents with all types listed. + + :param int start_offset: the offset from which to start the build + """ + result = {} + current_offset = start_offset + for start, (use, length) in sorted(iterable, key=lambda x: x[0]): + if start > current_offset: + result[current_offset] = ( + filler, + start - current_offset, + ) + result[start] = (use, length) + current_offset = start + length + + return result + + +class CapDevice: + """ + Layout on a cap device. + """ + + def __init__(self, encrypted): + self.extents = {} + self.encrypted = encrypted + + def add(self, *, allocs=None): + """ + Add specified values to the CapDevice's extents. + """ + allocs = [] if allocs is None else allocs + + for start, length in allocs: + self.extents[start] = (CapDeviceUses.ALLOCATED, length) + + return self + + def _offset(self): + return 0 if self.encrypted else SIZE_OF_CRYPT_METADATA_SECTORS + + def filled(self): + """ + Returns a copy of self with spaces filled with the unused value. + """ + return _filled(self.extents.items(), CapDeviceUses.UNUSED, self._offset()) + + def __str__(self): + return f"On crypt device: {self.encrypted}{os.linesep}" + os.linesep.join( + ( + f"({start}, {length}) {use}" + for (start, (use, length)) in sorted( + self.filled().items(), key=lambda x: x[0] + ) + ) + ) + + def check_overlap(self): + """ + Returns an error if allocations overlap + """ + return [ + f"Cap Device: {x}" + for x in _check_overlap(self.extents.items(), self._offset()) + ] + + +class BlockDevice: + """ + Layout on a block device. + """ + + def __init__(self): + self.extents = { + 0: (BlockDeviceUses.STRATIS_METADATA, SIZE_OF_STRATIS_METADATA_SECTORS) + } + + def add(self, *, integrity_meta_allocs=None, allocs=None): + """ + Add more layout on the device. + """ + integrity_meta_allocs = ( + [] if integrity_meta_allocs is None else integrity_meta_allocs + ) + + allocs = [] if allocs is None else allocs + + for start, length in integrity_meta_allocs: + self.extents[start] = (BlockDeviceUses.INTEGRITY_METADATA, length) + + for start, length in allocs: + self.extents[start] = (BlockDeviceUses.ALLOCATED, length) + + return self + + def filled(self): + """ + Returns a copy of self with spaces filled with the unused value. + """ + return _filled(self.extents.items(), BlockDeviceUses.UNUSED, 0) + + def __str__(self): + return os.linesep.join( + ( + f"({start}, {length}) {use}" + for (start, (use, length)) in sorted( + self.filled().items(), key=lambda x: x[0] + ) + ) + ) + + def check_integrity_meta_round(self): + """ + Check integrity metadata for rounding properties. + """ + errors = [] + + for length in ( + length + for (_, (use, length)) in self.extents.items() + if use is BlockDeviceUses.INTEGRITY_METADATA + ): + if length % 8 != 0: + errors.append( + f"integrity meta_allocs length {length} sectors is " + "not a multiple of 4KiB" + ) + + return errors + + def check_overlap(self): + """ + Returns an error if allocations overlap + """ + return [f"Block Device: {x}" for x in _check_overlap(self.extents.items(), 0)] + + +class CryptAllocs: + """ + Represents the allocations for crypt metadata. + """ + + def __init__(self): + """ + Initializer. + """ + self.extents = {} + + def add(self, *, allocs=None): + """ + Add allocations for crypt metadata. + + :param allocs: allocations for crypt metadata + :type + """ + allocs = [] if allocs is None else allocs + + for start, length in allocs: + self.extents[start] = length + + return self + + def check_canonical(self): + """ + Check that crypt allocs are what we expect them to be for the + foreseeable future. + """ + errors = [] + + if len(self.extents) > 1: + errors.append("No allocations for crypt metadata") + + if len(self.extents) == 0: + errors.append("Multiple allocations for crypt metadata") + + (start, length) = list(self.extents.items())[0] + + if start != 0: + errors.append(f"Crypt meta allocs offset, {start} sectors, is not 0") + + if length != 32768: + errors.append( + f"Crypt meta allocs entry has unexpected length {length} sectors" + ) + + return errors + + def __str__(self): + return os.linesep.join( + (f"({start}, {length})" for (start, length) in sorted(self.extents.items())) + ) + + +def _block_devices(metadata): + """ + Returns a map of BlockDevice objects with key = UUID + """ + data_tier_devs = metadata[Json.BACKSTORE][Json.DATA_TIER][Json.BLOCKDEV][Json.DEVS] + + bds = defaultdict( + BlockDevice, + ( + ( + UUID(dev[Json.UUID]), + BlockDevice().add( + integrity_meta_allocs=(dev.get(Json.INTEGRITY_META_ALLOCS) or []) + ), + ) + for dev in data_tier_devs + ), + ) + + assert len(bds) == len(data_tier_devs), "UUID collision found" + + data_tier_allocs = metadata[Json.BACKSTORE][Json.DATA_TIER][Json.BLOCKDEV][ + Json.ALLOCS + ][0] + + for item in data_tier_allocs: + bds[UUID(item[Json.PARENT])].add(allocs=[[item[Json.START], item[Json.LENGTH]]]) + + return bds + + +def _cap_device(metadata, encrypted=False): + """ + Returns a cap device. + """ + cap_device = CapDevice(encrypted) + + cap_device.add(allocs=metadata[Json.BACKSTORE][Json.CAP][Json.ALLOCS]) + + return cap_device + + +def _crypt_allocs(metadata): + """ + Get info about allocations for crypt metadata. + """ + return CryptAllocs().add(allocs=metadata["backstore"]["cap"]["crypt_meta_allocs"]) + + +def check(metadata): + """ + Check pool-level metadata for consistency. + + :param metadata: all the pool-level metadata. + :type metadata: Python JSON representation + :return: list of str + """ + + errors = [] + + block_devices = _block_devices(metadata) + + for bd in block_devices.values(): + errors.extend(bd.check_integrity_meta_round()) + errors.extend(bd.check_overlap()) + + crypt_allocs = _crypt_allocs(metadata) + + errors.extend(crypt_allocs.check_canonical()) + + cap_device = _cap_device( + metadata, Feature.ENCRYPTION in (metadata.get(Json.FEATURES) or []) + ) + errors.extend(cap_device.check_overlap()) + + return [str(x) for x in errors] + + +def _print(metadata): + """ + Print a human readable representation of the layout of some parts of + the stack. + """ + + block_devices = _block_devices(metadata) + + for uuid, dev in block_devices.items(): + print(f"Device UUId: {uuid}") + print(dev) + + crypt_allocs = _crypt_allocs(metadata) + print("") + print("Allocations for crypt metadata") + print(f"{crypt_allocs}") + + cap_device = _cap_device( + metadata, Feature.ENCRYPTION in (metadata.get(Json.FEATURES) or []) + ) + + print("") + print("Cap Device:") + print(f"{cap_device}") + + +def _gen_parser(): + """ + Generate the parser. + """ + parser = argparse.ArgumentParser( + description=("Inspect Stratis pool-level metadata.") + ) + + parser.add_argument("file", help="The file with the pool-level metadata") + + parser.add_argument( + "--print", + action="store_true", + help="print a human readable view of the storage stack", + ) + return parser + + +def main(): + """ + The main method. + """ + + parser = _gen_parser() + + args = parser.parse_args() + + with open(args.file, "r", encoding="utf-8") as infile: + metadata = json.load(infile) + + if args.print: + _print(metadata) + else: + errors = check(metadata) + if errors: + raise RuntimeError(errors) + + +if __name__ == "__main__": + main() diff --git a/testlib/infra.py b/testlib/infra.py index 9eba459..804d61e 100644 --- a/testlib/infra.py +++ b/testlib/infra.py @@ -32,6 +32,7 @@ import dbus from justbytes import Range +from .check_metadata import check from .dbus import StratisDbus, manager_interfaces from .utils import exec_command, process_exists, terminate_traces @@ -269,37 +270,6 @@ def _check_encryption_information_consistency(self, pool_object_path, metadata): elif features is not None: self.assertNotIn("Encryption", metadata["features"]) - def _check_crypt_meta_allocs(self, metadata): - """ - Check that all crypt metadata allocs exist and have non-zero length. - """ - crypt_meta_allocs = metadata["backstore"]["cap"].get("crypt_meta_allocs") - self.assertIsNotNone(crypt_meta_allocs) - self.assertIsInstance(crypt_meta_allocs, list) - self.assertGreater(len(crypt_meta_allocs), 0) - - crypt_meta_allocs = crypt_meta_allocs[0] - self.assertIsInstance(crypt_meta_allocs, list) - self.assertEqual(crypt_meta_allocs[0], 0) - self.assertGreater(crypt_meta_allocs[1], 0) - - def _check_integrity_meta_allocs(self, metadata): - """ - Check that all integrity_meta_allocs exist and have non-zero length. - """ - for integrity_meta_allocs in [ - a["integrity_meta_allocs"] - for a in metadata["backstore"]["data_tier"]["blockdev"]["devs"] - ]: - self.assertIsNotNone(integrity_meta_allocs) - self.assertIsInstance(integrity_meta_allocs, list) - self.assertGreater(len(integrity_meta_allocs), 0) - - for alloc in integrity_meta_allocs: - start, length = Range(alloc[0], 512), Range(alloc[1], 512) - self.assertGreater(start, Range(0)) - self.assertEqual(length % Range(8, 512), Range(0)) - def run_check(self, stop_time): """ Run the check. @@ -331,10 +301,7 @@ def run_check(self, stop_time): self._check_thin_meta_allocations(written) self._check_encryption_information_consistency(object_path, written) - self._check_crypt_meta_allocs(written) - - self._check_integrity_meta_allocs(written) - + self.assertEqual(check(written), []) else: current_message = ( "" if current_return_code == _OK else current_message