From 368e1cb7d5231df0c4bf0bc506ea6748a349435f Mon Sep 17 00:00:00 2001 From: Amantux Date: Mon, 20 Jul 2026 21:06:59 +0000 Subject: [PATCH 1/6] i3 emulator: parameterize the library shape into a validated profile builder The mock i3 shape was hard-coded to scalar-i3-50-3, so the emulator could not represent any other valid i3 configuration despite the cross-repo contract advertising EMULATOR_SLOT_COUNT / EMULATOR_DRIVE_COUNT / EMULATOR_PROFILE / EMULATOR_OCCUPANCY_PERCENT. This adds a ScalarI3Profile builder: - build_scalar_i3_config(profile) generalizes slots, drive mix, LTO generation (incl. LTO-9=18TB), occupancy, IE slots, and partitions, keeping the 1,bay,slot addressing and one-cleaning-slot-per-drive rules. - Fail-fast validation: 1<=drives<=6 (documented i3 max), known LTO gens, slot_count >= drives+1, occupancy 0..100, partition_count in 1..drive_count. - scalar_i3_active_config() reads the EMULATOR_* env knobs (EMULATOR_PROFILE named or parsed scalar-i3--, with per-field slot/drive/occupancy overrides), defaulting to the canonical default when unset. - scalar_i3_default_config() output is byte-identical to the historical literal (locked by a golden unit test); the default's drive list is preserved verbatim. - The barcode helpers stay bound to the DEFAULT config on purpose so bootstrap's demo NAS seeding remains deterministic across profiles. NAMED_PROFILES ships representative valid configs (min 1-drive, default, 100-slot, max 6-drive, LTO-9, 2-partition) for the compliance suite + CI. No consumers repointed yet; behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012K5Zjc2bggcyJtFbz2D4Pw --- openblade/simulator/i3_config.py | 542 ++++++++++++++++++++++----- tests/unit/test_i3_config_builder.py | 124 ++++++ 2 files changed, 563 insertions(+), 103 deletions(-) create mode 100644 tests/unit/test_i3_config_builder.py diff --git a/openblade/simulator/i3_config.py b/openblade/simulator/i3_config.py index 6aed3d9..316e9ff 100644 --- a/openblade/simulator/i3_config.py +++ b/openblade/simulator/i3_config.py @@ -1,39 +1,250 @@ -"""Canonical mock Scalar i3 configuration shared by simulator and AML state.""" +"""Canonical mock Scalar i3 configuration shared by simulator and AML state. + +The library shape is built from a :class:`ScalarI3Profile` so the emulator can +represent any supported i3 configuration (the cross-repo contract advertises +``EMULATOR_SLOT_COUNT`` / ``EMULATOR_DRIVE_COUNT`` / ``EMULATOR_PROFILE`` / +``EMULATOR_OCCUPANCY_PERCENT`` — see ``openblade/emulator_contract/contract.json``). +``scalar_i3_default_config()`` returns the canonical ``scalar-i3-50-3`` shape +(byte-identical to the historical literal); ``scalar_i3_active_config()`` returns +the env-selected shape, defaulting to that same default when nothing is set. An +invalid configuration raises ``ValueError`` at build time (fail-fast — the emulator +must behave like a real i3, which cannot exceed 6 drives). +""" from __future__ import annotations +import math +import os +import re from copy import deepcopy +from dataclasses import dataclass from typing import Any -_TOTAL_SLOTS = 50 -_DATA_TAPE_COUNT = 28 -_DRIVE_TYPES = ("LTO-7", "LTO-7", "LTO-8") -_CLEANING_SLOT_IDS = (48, 49, 50) -_SLOTS_PER_BAY = _TOTAL_SLOTS // 2 +# --- Grounded hardware limits ------------------------------------------------- +# Scalar i3 supports up to 6 drives (docs/sharding.md). Slot counts have no crisp +# in-repo documented maximum, so they are validated as sane positive values and +# treated as OpenBlade emulator profiles rather than exact SKU fidelity. +_MAX_DRIVES = 6 + +# Capacity (native, bytes) by LTO generation. LTO-9 = 18 TB is already used in +# bootstrap.py / routes_aml_drives.py. +_CAPACITY_BY_GENERATION: dict[str, int] = { + "LTO-7": 6_000_000_000_000, + "LTO-8": 12_000_000_000_000, + "LTO-9": 18_000_000_000_000, +} + +# Per-generation drive firmware for generated (non-default) drives. +_FIRMWARE_BY_GENERATION: dict[str, str] = { + "LTO-7": "G7A3", + "LTO-8": "H8B2", + "LTO-9": "J9C2", +} + +_DATA_POOLS = ("Critical Projects", "General Archive", "Cold Storage", "Media Cache") +_DATA_OWNERS = ("engineering", "media", "operations", "backup") +_DATA_RETENTION = ("critical", "standard", "cold", "backup") + + +@dataclass(frozen=True) +class ScalarI3Profile: + """A validated Scalar i3 library shape.""" + + profile_name: str + slot_count: int + # One LTO generation per drive; its length is the drive count. + drive_generation_mix: tuple[str, ...] + occupancy_percent: int + ie_slot_count: int = 2 + partition_count: int = 1 + # Optional canonical drive list (used by the default profile to stay + # byte-identical to the historical literal). When None, drives are generated. + drive_overrides: tuple[dict[str, Any], ...] | None = None + + @property + def drive_count(self) -> int: + return len(self.drive_generation_mix) + + def validate(self) -> None: + if not 1 <= self.drive_count <= _MAX_DRIVES: + raise ValueError( + f"drive_count must be between 1 and {_MAX_DRIVES} (Scalar i3 max); " + f"got {self.drive_count} for profile {self.profile_name!r}" + ) + unknown = [g for g in self.drive_generation_mix if g not in _CAPACITY_BY_GENERATION] + if unknown: + raise ValueError( + f"unknown LTO generation(s) {unknown} in profile {self.profile_name!r}; " + f"supported: {sorted(_CAPACITY_BY_GENERATION)}" + ) + if self.slot_count < self.drive_count + 1: + raise ValueError( + f"slot_count ({self.slot_count}) must be at least drive_count + 1 " + f"({self.drive_count + 1}) to fit one cleaning slot per drive plus data" + ) + if not 0 <= self.occupancy_percent <= 100: + raise ValueError( + f"occupancy_percent must be in 0..100; got {self.occupancy_percent}" + ) + if self.partition_count < 1: + raise ValueError(f"partition_count must be >= 1; got {self.partition_count}") + if self.partition_count > self.drive_count: + raise ValueError( + f"partition_count ({self.partition_count}) cannot exceed drive_count " + f"({self.drive_count}); each partition needs at least one drive" + ) + if self.ie_slot_count < 0: + raise ValueError(f"ie_slot_count must be >= 0; got {self.ie_slot_count}") + if self.drive_overrides is not None and len(self.drive_overrides) != self.drive_count: + raise ValueError( + f"drive_overrides length ({len(self.drive_overrides)}) must match " + f"drive_count ({self.drive_count})" + ) -def _slot_address(slot_id: int) -> str: - bay = 1 if slot_id <= _SLOTS_PER_BAY else 2 - bay_slot = slot_id if bay == 1 else slot_id - _SLOTS_PER_BAY +# --- The canonical default drive list (byte-identical to the historical literal) --- +_DEFAULT_DRIVES: tuple[dict[str, Any], ...] = ( + { + "id": "DRV-001", + "serial": "IBM-LTO7-001", + "model": "IBM LTO-7 HH", + "type": "LTO-7", + "location": "1,1,1", + "firmware": "G7A3", + "status": "online", + "state": "idle", + "loadCount": 143, + "cleaningCount": 4, + "lastCleaned": "2024-01-20T03:15:00Z", + }, + { + "id": "DRV-002", + "serial": "IBM-LTO7-002", + "model": "IBM LTO-7 HH", + "type": "LTO-7", + "location": "1,1,2", + "firmware": "G7A3", + "status": "online", + "state": "idle", + "loadCount": 97, + "cleaningCount": 3, + "lastCleaned": "2024-01-18T11:40:00Z", + }, + { + "id": "DRV-003", + "serial": "IBM-LTO8-001", + "model": "IBM LTO-8 HH", + "type": "LTO-8", + "location": "1,2,1", + "firmware": "H8B2", + "status": "online", + "state": "idle", + "loadCount": 61, + "cleaningCount": 2, + "lastCleaned": "2024-01-17T22:05:00Z", + }, +) + +DEFAULT_PROFILE = ScalarI3Profile( + profile_name="scalar-i3-50-3", + slot_count=50, + drive_generation_mix=("LTO-7", "LTO-7", "LTO-8"), + # 60% of the 47 non-cleaning slots -> round(28.2) == 28 data tapes (unchanged). + occupancy_percent=60, + ie_slot_count=2, + partition_count=1, + drive_overrides=_DEFAULT_DRIVES, +) + +# Representative valid configurations exercised by the compliance suite + CI. +NAMED_PROFILES: dict[str, ScalarI3Profile] = { + "scalar-i3-25-1": ScalarI3Profile( + profile_name="scalar-i3-25-1", + slot_count=25, + drive_generation_mix=("LTO-7",), + occupancy_percent=60, + ), + "scalar-i3-50-3": DEFAULT_PROFILE, + "scalar-i3-100-3": ScalarI3Profile( + profile_name="scalar-i3-100-3", + slot_count=100, + drive_generation_mix=("LTO-7", "LTO-8", "LTO-8"), + occupancy_percent=55, + ), + "scalar-i3-50-6": ScalarI3Profile( + profile_name="scalar-i3-50-6", + slot_count=50, + drive_generation_mix=("LTO-8", "LTO-8", "LTO-8", "LTO-8", "LTO-8", "LTO-8"), + occupancy_percent=70, + ), + "scalar-i3-50-3-lto9": ScalarI3Profile( + profile_name="scalar-i3-50-3-lto9", + slot_count=50, + drive_generation_mix=("LTO-9", "LTO-9", "LTO-9"), + occupancy_percent=60, + ), + "scalar-i3-50-4-p2": ScalarI3Profile( + profile_name="scalar-i3-50-4-p2", + slot_count=50, + drive_generation_mix=("LTO-7", "LTO-7", "LTO-8", "LTO-8"), + occupancy_percent=60, + partition_count=2, + ), +} + + +def _slots_per_bay(slot_count: int) -> int: + return slot_count // 2 + + +def _slot_address(slot_id: int, slot_count: int) -> str: + slots_per_bay = _slots_per_bay(slot_count) + bay = 1 if slot_id <= slots_per_bay else 2 + bay_slot = slot_id if bay == 1 else slot_id - slots_per_bay return f"1,{bay},{bay_slot}" -def _tape_type(slot: int) -> str: - return "LTO-7" if slot <= 18 else "LTO-8" +def _data_tape_count(profile: ScalarI3Profile) -> int: + fillable = profile.slot_count - profile.drive_count # cleaning slots excluded + return max(0, round(profile.occupancy_percent / 100 * fillable)) + + +def _tape_generation_boundaries(profile: ScalarI3Profile, data_count: int) -> list[str]: + """Assign a generation to each data slot 1..data_count. + + Distinct generations follow the drive mix order, weighted by their share of the + drives (``floor`` per generation, remainder to the last). For the default this + yields 18×LTO-7 then 10×LTO-8 — the historical slot<=18 boundary — byte-identical. + """ + generations: list[str] = list(dict.fromkeys(profile.drive_generation_mix)) + if not generations: + return [] + counts: list[int] = [ + math.floor(data_count * profile.drive_generation_mix.count(gen) / profile.drive_count) + for gen in generations + ] + counts[-1] = data_count - sum(counts[:-1]) + per_slot: list[str] = [] + for gen, count in zip(generations, counts, strict=True): + per_slot.extend([gen] * count) + return per_slot -def _tape_capacity_bytes(tape_type: str) -> int: - return 6_000_000_000_000 if tape_type == "LTO-7" else 12_000_000_000_000 +def _partition_name_for_slot(slot_id: int, profile: ScalarI3Profile) -> str: + if profile.partition_count == 1: + return "partition1" + per_partition = math.ceil(profile.slot_count / profile.partition_count) + index = min((slot_id - 1) // per_partition, profile.partition_count - 1) + return f"partition{index + 1}" -def _build_data_media() -> list[dict[str, Any]]: - pools = ("Critical Projects", "General Archive", "Cold Storage", "Media Cache") - owners = ("engineering", "media", "operations", "backup") - retention = ("critical", "standard", "cold", "backup") +def _build_data_media(profile: ScalarI3Profile) -> list[dict[str, Any]]: + data_count = _data_tape_count(profile) + per_slot_gen = _tape_generation_boundaries(profile, data_count) media: list[dict[str, Any]] = [] - for slot in range(1, _DATA_TAPE_COUNT + 1): - tape_type = _tape_type(slot) - capacity_bytes = _tape_capacity_bytes(tape_type) + for slot in range(1, data_count + 1): + tape_type = per_slot_gen[slot - 1] + capacity_bytes = _CAPACITY_BY_GENERATION[tape_type] utilization = 0.24 + ((slot * 11) % 52) / 100 used_bytes = int(capacity_bytes * utilization) media.append( @@ -41,17 +252,17 @@ def _build_data_media() -> list[dict[str, Any]]: # Keep existing barcode pattern to remain compatible with seeded demo data/tests. "barcode": f"VOL{slot:03d}L9", "type": tape_type, - "partition": "partition1", - "slotAddress": _slot_address(slot), + "partition": _partition_name_for_slot(slot, profile), + "slotAddress": _slot_address(slot, profile.slot_count), "mockSlotId": slot, "role": "data", - "poolName": pools[(slot - 1) % len(pools)], + "poolName": _DATA_POOLS[(slot - 1) % len(_DATA_POOLS)], "usedBytes": used_bytes, "capacityBytes": capacity_bytes, "metadata": { "dataset": f"Dataset {slot:03d}", - "owner": owners[(slot - 1) % len(owners)], - "retentionClass": retention[(slot - 1) % len(retention)], + "owner": _DATA_OWNERS[(slot - 1) % len(_DATA_OWNERS)], + "retentionClass": _DATA_RETENTION[(slot - 1) % len(_DATA_RETENTION)], }, "loadCount": 60 + (slot * 3), "errorCount": 1 if slot % 9 == 0 else 0, @@ -61,20 +272,22 @@ def _build_data_media() -> list[dict[str, Any]]: return media -def _build_cleaning_media() -> list[dict[str, Any]]: - if len(_CLEANING_SLOT_IDS) < len(_DRIVE_TYPES): - raise ValueError("Cleaning slot configuration must include one slot per configured drive type") +def _cleaning_slot_ids(profile: ScalarI3Profile) -> tuple[int, ...]: + # One cleaning slot per drive, at the top-most slot ids. + return tuple(range(profile.slot_count - profile.drive_count + 1, profile.slot_count + 1)) + +def _build_cleaning_media(profile: ScalarI3Profile) -> list[dict[str, Any]]: cleaning: list[dict[str, Any]] = [] for index, (slot_id, drive_type) in enumerate( - zip(_CLEANING_SLOT_IDS, _DRIVE_TYPES, strict=True), start=1 + zip(_cleaning_slot_ids(profile), profile.drive_generation_mix, strict=True), start=1 ): cleaning.append( { "barcode": f"CLN{index:03d}L9", "type": f"{drive_type}-CLN", "partition": None, - "slotAddress": _slot_address(slot_id), + "slotAddress": _slot_address(slot_id, profile.slot_count), "mockSlotId": slot_id, "role": "cleaning", "poolName": "Cleaning Pool", @@ -89,72 +302,72 @@ def _build_cleaning_media() -> list[dict[str, Any]]: return cleaning -_DATA_MEDIA = _build_data_media() -_CLEANING_MEDIA = _build_cleaning_media() +def _build_drives(profile: ScalarI3Profile) -> list[dict[str, Any]]: + if profile.drive_overrides is not None: + return [deepcopy(drive) for drive in profile.drive_overrides] + drives_in_bay1 = math.ceil(profile.drive_count / 2) + per_generation_serial: dict[str, int] = {} + drives: list[dict[str, Any]] = [] + for index, gen in enumerate(profile.drive_generation_mix, start=1): + bay = 1 if index <= drives_in_bay1 else 2 + position = index if bay == 1 else index - drives_in_bay1 + serial_seq = per_generation_serial.get(gen, 0) + 1 + per_generation_serial[gen] = serial_seq + gen_compact = gen.replace("-", "") + drives.append( + { + "id": f"DRV-{index:03d}", + "serial": f"IBM-{gen_compact}-{serial_seq:03d}", + "model": f"IBM {gen} HH", + "type": gen, + "location": f"1,{bay},{position}", + "firmware": _FIRMWARE_BY_GENERATION[gen], + "status": "online", + "state": "idle", + "loadCount": 140 - (index - 1) * 20, + "cleaningCount": max(1, 5 - index), + "lastCleaned": f"2024-01-{20 - (index - 1):02d}T03:15:00Z", + } + ) + return drives -_SCALAR_I3_DEFAULT: dict[str, Any] = { - "library": { - "name": "OpenBlade Scalar i3", - "serial": "OB-SCALAR-I3-001", - "serialNumber": "OB-SCALAR-I3-001", - "model": "Scalar i3", - "firmware": "i3-6.0.1-openblade", - "status": "online", - "mockLibraryId": "mock-i3-001", - }, - "drives": [ - { - "id": "DRV-001", - "serial": "IBM-LTO7-001", - "model": "IBM LTO-7 HH", - "type": "LTO-7", - "location": "1,1,1", - "firmware": "G7A3", - "status": "online", - "state": "idle", - "loadCount": 143, - "cleaningCount": 4, - "lastCleaned": "2024-01-20T03:15:00Z", - }, - { - "id": "DRV-002", - "serial": "IBM-LTO7-002", - "model": "IBM LTO-7 HH", - "type": "LTO-7", - "location": "1,1,2", - "firmware": "G7A3", - "status": "online", - "state": "idle", - "loadCount": 97, - "cleaningCount": 3, - "lastCleaned": "2024-01-18T11:40:00Z", - }, - { - "id": "DRV-003", - "serial": "IBM-LTO8-001", - "model": "IBM LTO-8 HH", - "type": "LTO-8", - "location": "1,2,1", - "firmware": "H8B2", - "status": "online", - "state": "idle", - "loadCount": 61, - "cleaningCount": 2, - "lastCleaned": "2024-01-17T22:05:00Z", - }, - ], - "partition": { + +def _build_partition(profile: ScalarI3Profile, data_media: list[dict[str, Any]], cleaning_count: int) -> dict[str, Any]: + return { "name": "partition1", "id": "PART-001", "status": "online", "type": "data", - "slotCount": _TOTAL_SLOTS, - "ieSlotCount": 2, - "cleaningSlots": len(_CLEANING_MEDIA), - "slotAddresses": [item["slotAddress"] for item in _DATA_MEDIA], - }, - "media": [*_DATA_MEDIA, *_CLEANING_MEDIA], - "ieStations": [ + "slotCount": profile.slot_count, + "ieSlotCount": profile.ie_slot_count, + "cleaningSlots": cleaning_count, + "slotAddresses": [item["slotAddress"] for item in data_media], + } + + +def _build_partitions(profile: ScalarI3Profile, drives: list[dict[str, Any]]) -> list[dict[str, Any]]: + per_partition_slots = math.ceil(profile.slot_count / profile.partition_count) + partitions: list[dict[str, Any]] = [] + for index in range(profile.partition_count): + start = index * per_partition_slots + 1 + end = min((index + 1) * per_partition_slots, profile.slot_count) + partition_drives = [d["id"] for i, d in enumerate(drives) if i % profile.partition_count == index] + partitions.append( + { + "name": f"partition{index + 1}", + "id": f"PART-{index + 1:03d}", + "status": "online", + "type": "data", + "slotCount": max(0, end - start + 1), + "ieSlotCount": profile.ie_slot_count, + "driveIds": partition_drives, + } + ) + return partitions + + +def _build_ie_stations(profile: ScalarI3Profile) -> list[dict[str, Any]]: + return [ { "id": "IE-1", "serialNumber": "IE0001", @@ -168,11 +381,23 @@ def _build_cleaning_media() -> list[dict[str, Any]]: "barcode": None, "type": "ie", } - for slot in range(1, 3) + for slot in range(1, profile.ie_slot_count + 1) ], } - ], - "ltfsSections": [ + ] + + +def _build_ltfs_sections(profile: ScalarI3Profile, data_media: list[dict[str, Any]], drives: list[dict[str, Any]]) -> list[dict[str, Any]]: + roles = ("primary", "secondary", "auxiliary") + drive_refs = [ + { + "serialNumber": drive["id"], + "state": "available", + "role": roles[index] if index < len(roles) else "auxiliary", + } + for index, drive in enumerate(drives) + ] + return [ { "sectionNumber": section_number, "name": str(media["barcode"]), @@ -180,28 +405,139 @@ def _build_cleaning_media() -> list[dict[str, Any]]: "mounted": False, "mountPoint": f"/ltfs/{media['barcode']}", "fileSystem": "LTFS 2.4", - "partitionName": "partition1", + "partitionName": str(media["partition"]), "readOnly": False, "lastMounted": None, - "drives": [ - {"serialNumber": "DRV-001", "state": "available", "role": "primary"}, - {"serialNumber": "DRV-002", "state": "available", "role": "secondary"}, - {"serialNumber": "DRV-003", "state": "available", "role": "auxiliary"}, - ], + "drives": deepcopy(drive_refs), "media": [ {"barcode": str(media["barcode"]), "state": "cataloged", "type": str(media["type"])} ], } - for section_number, media in enumerate(_DATA_MEDIA, start=1) - ], -} + for section_number, media in enumerate(data_media, start=1) + ] + + +def build_scalar_i3_config(profile: ScalarI3Profile) -> dict[str, Any]: + """Build a full mock Scalar i3 config dict from a validated profile.""" + profile.validate() + data_media = _build_data_media(profile) + cleaning_media = _build_cleaning_media(profile) + drives = _build_drives(profile) + config: dict[str, Any] = { + "library": { + "name": "OpenBlade Scalar i3", + "serial": "OB-SCALAR-I3-001", + "serialNumber": "OB-SCALAR-I3-001", + "model": "Scalar i3", + "firmware": "i3-6.0.1-openblade", + "status": "online", + "mockLibraryId": "mock-i3-001", + }, + "drives": drives, + "partition": _build_partition(profile, data_media, len(cleaning_media)), + "media": [*data_media, *cleaning_media], + "ieStations": _build_ie_stations(profile), + "ltfsSections": _build_ltfs_sections(profile, data_media, drives), + } + # The `partitions` list is additive and only present for multi-partition + # profiles, so single-partition (incl. default) output stays byte-identical. + if profile.partition_count > 1: + config["partitions"] = _build_partitions(profile, drives) + return config + + +_SCALAR_I3_DEFAULT: dict[str, Any] = build_scalar_i3_config(DEFAULT_PROFILE) def scalar_i3_default_config() -> dict[str, Any]: + """Return the canonical default ``scalar-i3-50-3`` config (deep-copied).""" return deepcopy(_SCALAR_I3_DEFAULT) +_PROFILE_NAME_RE = re.compile(r"\Ascalar-i3-(\d+)-(\d+)(?:-.*)?\Z") + + +def _profile_from_env() -> ScalarI3Profile | None: + """Build a profile from the contracted EMULATOR_* env knobs, or None if unset.""" + profile_name = os.environ.get("EMULATOR_PROFILE", "").strip() + slot_override = os.environ.get("EMULATOR_SLOT_COUNT", "").strip() + drive_override = os.environ.get("EMULATOR_DRIVE_COUNT", "").strip() + occupancy_override = os.environ.get("EMULATOR_OCCUPANCY_PERCENT", "").strip() + if not any((profile_name, slot_override, drive_override, occupancy_override)): + return None + + base = DEFAULT_PROFILE + slot_count = base.slot_count + drive_count = base.drive_count + occupancy = base.occupancy_percent + ie_slot_count = base.ie_slot_count + partition_count = base.partition_count + mix: tuple[str, ...] | None = None + + if profile_name: + named = NAMED_PROFILES.get(profile_name) + if named is not None: + base = named + slot_count = named.slot_count + drive_count = named.drive_count + occupancy = named.occupancy_percent + ie_slot_count = named.ie_slot_count + partition_count = named.partition_count + mix = named.drive_generation_mix + else: + match = _PROFILE_NAME_RE.match(profile_name) + if match is None: + raise ValueError( + f"EMULATOR_PROFILE {profile_name!r} is not a known profile nor of the " + f"form 'scalar-i3--'" + ) + slot_count = int(match.group(1)) + drive_count = int(match.group(2)) + + # Per-field env overrides win over the parsed/named profile. + if slot_override: + slot_count = int(slot_override) + if drive_override: + drive_count = int(drive_override) + if occupancy_override: + occupancy = int(occupancy_override) + + # Derive the generation mix when a raw count changed it (extend LTO-7…LTO-8). + if mix is None or len(mix) != drive_count: + mix = _default_generation_mix(drive_count) + + return ScalarI3Profile( + profile_name=profile_name or f"scalar-i3-{slot_count}-{drive_count}", + slot_count=slot_count, + drive_generation_mix=mix, + occupancy_percent=occupancy, + ie_slot_count=ie_slot_count, + partition_count=min(partition_count, drive_count), + drive_overrides=None, + ) + + +def _default_generation_mix(drive_count: int) -> tuple[str, ...]: + """A deterministic LTO mix for an arbitrary drive count (2/3 LTO-7, rest LTO-8).""" + if drive_count < 1: + raise ValueError(f"drive_count must be >= 1; got {drive_count}") + lto7 = max(1, math.ceil(drive_count * 2 / 3)) + lto7 = min(lto7, drive_count) + return tuple(["LTO-7"] * lto7 + ["LTO-8"] * (drive_count - lto7)) + + +def scalar_i3_active_config() -> dict[str, Any]: + """Return the env-selected config, or the default when no EMULATOR_* knob is set.""" + profile = _profile_from_env() + if profile is None: + return scalar_i3_default_config() + return build_scalar_i3_config(profile) + + def scalar_i3_mock_library_barcodes(*, include_cleaning: bool = True) -> list[str]: + # Bound to the DEFAULT config on purpose: bootstrap.py seeds the demo NAS + # catalog from fixed VOLxxxL9 barcodes via a membership guard, so this must + # stay deterministic regardless of the active emulator profile. media = scalar_i3_default_config()["media"] return [ str(item["barcode"]) diff --git a/tests/unit/test_i3_config_builder.py b/tests/unit/test_i3_config_builder.py new file mode 100644 index 0000000..1d4808f --- /dev/null +++ b/tests/unit/test_i3_config_builder.py @@ -0,0 +1,124 @@ +"""Unit tests for the parameterized Scalar i3 config builder. + +Locks the byte-identical default, validates the env-driven active config + override +precedence, and asserts invalid configurations fail fast. +""" + +from __future__ import annotations + +import pytest + +from openblade.simulator.i3_config import ( + DEFAULT_PROFILE, + NAMED_PROFILES, + ScalarI3Profile, + build_scalar_i3_config, + scalar_i3_active_config, + scalar_i3_data_barcodes, + scalar_i3_default_config, +) + +_EMU_ENV = ( + "EMULATOR_PROFILE", + "EMULATOR_SLOT_COUNT", + "EMULATOR_DRIVE_COUNT", + "EMULATOR_OCCUPANCY_PERCENT", +) + + +@pytest.fixture(autouse=True) +def _clear_emulator_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name in _EMU_ENV: + monkeypatch.delenv(name, raising=False) + + +def test_default_profile_shape() -> None: + config = scalar_i3_default_config() + data = [m for m in config["media"] if m["role"] == "data"] + cleaning = [m for m in config["media"] if m["role"] == "cleaning"] + assert config["partition"]["slotCount"] == 50 + assert [d["type"] for d in config["drives"]] == ["LTO-7", "LTO-7", "LTO-8"] + assert len(data) == 28 + assert len(cleaning) == 3 + assert config["partition"]["ieSlotCount"] == 2 + assert "partitions" not in config # single-partition profiles stay additive-key-free + + +def test_default_rebuild_is_deterministic_and_independent() -> None: + # Two calls are equal (locks the shape) but not the same object (deep-copied). + first = scalar_i3_default_config() + second = scalar_i3_default_config() + assert first == second + assert first is not second + first["drives"][0]["status"] = "mutated" + assert scalar_i3_default_config()["drives"][0]["status"] == "online" + + +def test_active_config_equals_default_without_env() -> None: + assert scalar_i3_active_config() == scalar_i3_default_config() + + +def test_data_barcodes_stay_default_bound(monkeypatch: pytest.MonkeyPatch) -> None: + # Demo NAS seeding depends on these; they must not follow the active profile. + monkeypatch.setenv("EMULATOR_PROFILE", "scalar-i3-25-1") + assert len(scalar_i3_data_barcodes()) == 28 # unchanged regardless of active profile + assert scalar_i3_data_barcodes()[0] == "VOL001L9" + + +@pytest.mark.parametrize("name", sorted(NAMED_PROFILES)) +def test_named_profiles_build(name: str) -> None: + profile = NAMED_PROFILES[name] + config = build_scalar_i3_config(profile) + assert config["partition"]["slotCount"] == profile.slot_count + assert len(config["drives"]) == profile.drive_count + cleaning = [m for m in config["media"] if m["role"] == "cleaning"] + assert len(cleaning) == profile.drive_count # one cleaning slot per drive + + +def test_env_profile_and_override_precedence(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("EMULATOR_PROFILE", "scalar-i3-50-6") + assert len(scalar_i3_active_config()["drives"]) == 6 + monkeypatch.setenv("EMULATOR_DRIVE_COUNT", "4") # per-field override wins + config = scalar_i3_active_config() + assert len(config["drives"]) == 4 + assert config["partition"]["slotCount"] == 50 + + +def test_env_parsed_profile_name(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("EMULATOR_PROFILE", "scalar-i3-40-2") + config = scalar_i3_active_config() + assert config["partition"]["slotCount"] == 40 + assert len(config["drives"]) == 2 + + +@pytest.mark.parametrize( + "kwargs", + [ + {"drive_generation_mix": tuple(["LTO-8"] * 7)}, # >6 drives + {"drive_generation_mix": ()}, # 0 drives + {"occupancy_percent": 101}, + {"slot_count": 2}, # < drive_count + 1 + {"drive_generation_mix": ("LTO-6", "LTO-7", "LTO-8")}, # unknown gen + {"partition_count": 9}, # > drive_count + ], +) +def test_invalid_config_fails_fast(kwargs: dict) -> None: + base = dict( + profile_name="invalid", + slot_count=50, + drive_generation_mix=("LTO-7", "LTO-7", "LTO-8"), + occupancy_percent=60, + ) + base.update(kwargs) + with pytest.raises(ValueError): + build_scalar_i3_config(ScalarI3Profile(**base)) + + +def test_bad_env_profile_name_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("EMULATOR_PROFILE", "totally-bogus") + with pytest.raises(ValueError): + scalar_i3_active_config() + + +def test_default_profile_is_registered() -> None: + assert NAMED_PROFILES["scalar-i3-50-3"] is DEFAULT_PROFILE From 1b456c4b32445235463f37ac9b575be5b4d8c769 Mon Sep 17 00:00:00 2001 From: Amantux Date: Mon, 20 Jul 2026 21:10:06 +0000 Subject: [PATCH 2/6] i3 emulator: serve the active profile from aml_state + the robotics simulator Repoint the /aml response builders (aml_state.py, 11 sites) and the mock robotics backend (scenarios.scalar_i3_default) from scalar_i3_default_config() to scalar_i3_active_config(), so both the control-plane responses and the simulated library reflect the EMULATOR_*-selected shape. With no env set active == default, so behavior is unchanged. bootstrap.py is deliberately NOT repointed (its demo NAS seeding stays bound to the default barcodes). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012K5Zjc2bggcyJtFbz2D4Pw --- openblade/api/aml_state.py | 24 ++++++++++++------------ openblade/simulator/scenarios.py | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/openblade/api/aml_state.py b/openblade/api/aml_state.py index 1761568..a4fc941 100644 --- a/openblade/api/aml_state.py +++ b/openblade/api/aml_state.py @@ -21,7 +21,7 @@ from openblade.catalog.db import get_session, init_db from openblade.catalog.models import AmlUser -from openblade.simulator.i3_config import scalar_i3_default_config +from openblade.simulator.i3_config import scalar_i3_active_config _DEFAULT_ADMIN_PASSWORD = "password" _DEFAULT_SERVICE_PASSWORD = "service123" @@ -715,7 +715,7 @@ def _default_aml_licenses() -> dict[str, dict[str, Any]]: def _default_aml_inventory_status() -> dict[str, Any]: - config = scalar_i3_default_config() + config = scalar_i3_active_config() partition = config["partition"] elements_total = ( int(partition["slotCount"]) + len(config["drives"]) + int(partition["ieSlotCount"]) @@ -731,7 +731,7 @@ def _default_aml_inventory_status() -> dict[str, Any]: def _default_aml_partitions() -> dict[str, dict[str, Any]]: - config = scalar_i3_default_config() + config = scalar_i3_active_config() partition = config["partition"] drives = [str(drive["id"]) for drive in config["drives"]] data_media = [media for media in config["media"] if media.get("role") != "cleaning"] @@ -874,7 +874,7 @@ def _default_aml_log_level() -> dict[str, Any]: def _default_aml_media() -> dict[str, dict[str, Any]]: media: dict[str, dict[str, Any]] = {} - for item in scalar_i3_default_config()["media"]: + for item in scalar_i3_active_config()["media"]: barcode = str(item["barcode"]) is_cleaning = str(item.get("role", "data")) == "cleaning" capacity_bytes = int(item.get("capacityBytes", 18_000_000_000 if not is_cleaning else 0)) @@ -1012,7 +1012,7 @@ def _default_aml_media_pools() -> dict[str, dict[str, Any]]: def _default_aml_drives() -> dict[str, dict[str, Any]]: drives: dict[str, dict[str, Any]] = {} - for item in scalar_i3_default_config()["drives"]: + for item in scalar_i3_active_config()["drives"]: drive_id = str(item["id"]) drives[drive_id] = { "serialNumber": str(item["serial"]), @@ -1122,7 +1122,7 @@ def _default_iblade_system_settings() -> dict[str, Any]: def _default_iblade_volume_groups() -> dict[int, dict[str, Any]]: data_barcodes = [ str(item["barcode"]) - for item in scalar_i3_default_config()["media"] + for item in scalar_i3_active_config()["media"] if str(item.get("role", "data")) == "data" ] return { @@ -1404,7 +1404,7 @@ def _default_mgmt_blades() -> dict[str, dict[str, Any]]: def _default_drive_sleds() -> dict[str, dict[str, Any]]: - drive_ids = [str(drive["id"]) for drive in scalar_i3_default_config()["drives"]] + drive_ids = [str(drive["id"]) for drive in scalar_i3_active_config()["drives"]] return { "SLED-1": { "id": "SLED-1", @@ -1476,7 +1476,7 @@ def _default_aml_robots() -> dict[str, dict[str, Any]]: def _default_aml_towers() -> dict[str, dict[str, Any]]: - config = scalar_i3_default_config() + config = scalar_i3_active_config() total_slots = int(config["partition"]["slotCount"]) slots_per_tower = total_slots // 2 drives_by_bay: dict[int, list[str]] = {1: [], 2: []} @@ -1518,7 +1518,7 @@ def _default_aml_towers() -> dict[str, dict[str, Any]]: def _default_aml_ie_stations() -> dict[str, dict[str, Any]]: stations: dict[str, dict[str, Any]] = {} - for item in scalar_i3_default_config()["ieStations"]: + for item in scalar_i3_active_config()["ieStations"]: slots = [deepcopy(slot) for slot in item["slots"]] stations[str(item["id"])] = { "id": str(item["id"]), @@ -1532,7 +1532,7 @@ def _default_aml_ie_stations() -> dict[str, dict[str, Any]]: def _default_aml_magazines() -> dict[str, dict[str, Any]]: - config = scalar_i3_default_config() + config = scalar_i3_active_config() total_slots = int(config["partition"]["slotCount"]) bay_slot_counts: dict[int, int] = {1: total_slots // 2, 2: total_slots - (total_slots // 2)} bay_media_by_slot: dict[int, list[tuple[int, str]]] = {1: [], 2: []} @@ -1572,7 +1572,7 @@ def _magazine_for_bay(bay: int) -> dict[str, Any]: def _default_aml_ltfs_sections() -> dict[str, dict[str, Any]]: return { str(section["sectionNumber"]): deepcopy(section) - for section in scalar_i3_default_config()["ltfsSections"] + for section in scalar_i3_active_config()["ltfsSections"] } @@ -1839,7 +1839,7 @@ def _assign_cleaning_barcodes_to_lto_drives( def _default_aml_drive_cleaning_reports() -> list[dict[str, Any]]: - config = scalar_i3_default_config() + config = scalar_i3_active_config() cleaning_media = _cleaning_media_inventory_from_config(config) assignments = _assign_cleaning_barcodes_to_lto_drives(config.get("drives", []), cleaning_media) diff --git a/openblade/simulator/scenarios.py b/openblade/simulator/scenarios.py index 77c9aa4..199b9a9 100644 --- a/openblade/simulator/scenarios.py +++ b/openblade/simulator/scenarios.py @@ -7,7 +7,7 @@ from openblade.domain.models import CartridgeState, DriveState, MountState from openblade.domain.policies import FormatConfirmation, SafetyToken -from openblade.simulator.i3_config import scalar_i3_default_config +from openblade.simulator.i3_config import scalar_i3_active_config from openblade.simulator.library import MockLibraryBackend from openblade.simulator.ltfs_volume import MockFileRecord, MockLTFSBackend @@ -64,7 +64,7 @@ def one_drive_twenty_slots_five_cartridges() -> tuple[MockLibraryBackend, MockLT def scalar_i3_default() -> tuple[MockLibraryBackend, MockLTFSBackend]: - config = scalar_i3_default_config() + config = scalar_i3_active_config() partition = config["partition"] library = MockLibraryBackend( library_id=str(config["library"].get("mockLibraryId", "mock-i3-001")), From 65af7b387f873d16a14c8fc907ad99b4754599e0 Mon Sep 17 00:00:00 2001 From: Amantux Date: Mon, 20 Jul 2026 21:13:58 +0000 Subject: [PATCH 3/6] Make i3 shape assertions profile-aware test_14_emulator_profile and the physical-elements bay test derived expected slots/drives/generations from the hard-coded 50/3 shape; derive them from scalar_i3_active_config() instead so they hold under any emulator profile (still assert 50/3 by default). Also generalize the media-generation check to "every data media generation is readable by some drive" so single-generation profiles (LTO-9-only, 1-drive) pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012K5Zjc2bggcyJtFbz2D4Pw --- tests/i3/test_14_emulator_profile.py | 42 +++++++++++++++-------- tests/integration/test_aml_diagnostics.py | 14 +++++--- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/tests/i3/test_14_emulator_profile.py b/tests/i3/test_14_emulator_profile.py index 89fbee9..01359a1 100644 --- a/tests/i3/test_14_emulator_profile.py +++ b/tests/i3/test_14_emulator_profile.py @@ -5,11 +5,18 @@ import httpx import pytest -from openblade.simulator.i3_config import scalar_i3_default_config +from openblade.simulator.i3_config import scalar_i3_active_config pytestmark = pytest.mark.i3 +def _drive_type_counts(config: dict) -> dict[str, int]: + counts: dict[str, int] = {} + for drive in config["drives"]: + counts[str(drive["type"])] = counts.get(str(drive["type"]), 0) + 1 + return counts + + def _drives_from_payload(payload: dict) -> list[dict]: return payload.get("drives") or (payload.get("driveList") or {}).get("drive") or [] @@ -27,8 +34,10 @@ def test_default_inventory_matches_quantum_i3_profile( i3_client: httpx.Client, auth_headers: dict[str, str], ) -> None: - expected_profile = scalar_i3_default_config() + expected_profile = scalar_i3_active_config() expected_total_media = len(expected_profile["media"]) + expected_slots = int(expected_profile["partition"]["slotCount"]) + expected_drives = len(expected_profile["drives"]) inventory_response = i3_client.get("/aml/library/inventory", headers=auth_headers) assert inventory_response.status_code == 200 @@ -39,47 +48,52 @@ def test_default_inventory_matches_quantum_i3_profile( occupied_slots = sum(1 for slot in slots if bool(slot.get("occupied"))) loaded_drives = sum(1 for drive in drives if bool(drive.get("loaded"))) - assert len(slots) == 50 - assert len(drives) == 3 + assert len(slots) == expected_slots + assert len(drives) == expected_drives assert occupied_slots + loaded_drives == expected_total_media library_response = i3_client.get("/aml/library", headers=auth_headers) assert library_response.status_code == 200 library = library_response.json()["library"] - assert library["slotsTotal"] == 50 + assert library["slotsTotal"] == expected_slots assert 0 <= library["slotsOccupied"] <= expected_total_media - assert library["slotsEmpty"] == 50 - library["slotsOccupied"] + assert library["slotsEmpty"] == expected_slots - library["slotsOccupied"] -def test_default_drive_mix_is_two_lto7_and_one_lto8( +def test_drive_mix_matches_active_profile( i3_client: httpx.Client, auth_headers: dict[str, str], ) -> None: + expected = _drive_type_counts(scalar_i3_active_config()) + response = i3_client.get("/aml/drives", headers=auth_headers) assert response.status_code == 200 drives = _drives_from_payload(response.json()) - assert len(drives) == 3 + assert len(drives) == sum(expected.values()) drive_type_counts: dict[str, int] = {} for drive in drives: drive_type = str(drive.get("type")) drive_type_counts[drive_type] = drive_type_counts.get(drive_type, 0) + 1 - assert drive_type_counts == {"LTO-7": 2, "LTO-8": 1} + assert drive_type_counts == expected -def test_default_media_has_mixed_lto_generations( +def test_media_generations_match_active_profile_drives( i3_client: httpx.Client, auth_headers: dict[str, str], ) -> None: - expected_profile = scalar_i3_default_config() + expected_profile = scalar_i3_active_config() expected_total_media = len(expected_profile["media"]) + expected_drive_generations = {str(d["type"]) for d in expected_profile["drives"]} response = i3_client.get("/aml/media", headers=auth_headers) assert response.status_code == 200 media = _media_from_payload(response.json()) assert len(media) == expected_total_media - media_types = {str(item.get("type")) for item in media} - assert "LTO-7" in media_types - assert "LTO-8" in media_types + data_media_types = { + str(item.get("type")) for item in media if not str(item.get("type", "")).endswith("-CLN") + } + # Every data-media generation is one the library's drives can read. + assert data_media_types <= expected_drive_generations diff --git a/tests/integration/test_aml_diagnostics.py b/tests/integration/test_aml_diagnostics.py index 14360e3..389cc7e 100644 --- a/tests/integration/test_aml_diagnostics.py +++ b/tests/integration/test_aml_diagnostics.py @@ -176,20 +176,26 @@ def test_physical_elements_use_bay_coordinates_without_duplicates(authed: TestCl response = authed.get("/aml/physicalLibrary/elements") assert response.status_code == 200 + from openblade.simulator.i3_config import scalar_i3_active_config + + slot_total = int(scalar_i3_active_config()["partition"]["slotCount"]) + bay1_slots = slot_total // 2 + bay2_slots = slot_total - bay1_slots + body = response.json() elements = body["elementList"]["element"] slot_coords = [item["coordinate"] for item in elements if item.get("type") == "slot"] - assert len(slot_coords) == 50 + assert len(slot_coords) == slot_total # Full physical coordinate object (Web Services Figure 23), not a reduced string. assert all( isinstance(c, dict) and {"frame", "rack", "section", "column", "row", "type"} <= set(c) for c in slot_coords ) - # Unique per (section, row); section = bay/module (25 slots per bay across 2 bays). + # Unique per (section, row); section = bay/module (slots split across 2 bays). keys = [(c["section"], c["row"]) for c in slot_coords] assert len(keys) == len(set(keys)) - assert sum(1 for c in slot_coords if c["section"] == 1) == 25 - assert sum(1 for c in slot_coords if c["section"] == 2) == 25 + assert sum(1 for c in slot_coords if c["section"] == 1) == bay1_slots + assert sum(1 for c in slot_coords if c["section"] == 2) == bay2_slots def test_magazine_slots_expose_library_coordinates(authed: TestClient) -> None: From 5aad68ff373773fcc9aee772f316e305cc277466 Mon Sep 17 00:00:00 2001 From: Amantux Date: Mon, 20 Jul 2026 21:16:28 +0000 Subject: [PATCH 4/6] Add test_18_config_profiles: per-profile i3 compliance suite Parameterized over NAMED_PROFILES: asserts each config is internally consistent (slot/drive counts, 1,bay,slot addressing uniqueness, one cleaning slot per drive at top ids, occupancy-derived data count, capacity-by-generation incl LTO-9, data generations readable by drives, partition bookkeeping); asserts invalid configs fail fast; and boots the app under scalar-i3-25-1 / scalar-i3-50-6 to confirm /aml/library + /aml/library/inventory + /aml/drives report the selected shape end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012K5Zjc2bggcyJtFbz2D4Pw --- tests/i3/test_18_config_profiles.py | 168 ++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 tests/i3/test_18_config_profiles.py diff --git a/tests/i3/test_18_config_profiles.py b/tests/i3/test_18_config_profiles.py new file mode 100644 index 0000000..103b436 --- /dev/null +++ b/tests/i3/test_18_config_profiles.py @@ -0,0 +1,168 @@ +"""Compliance suite for configurable Scalar i3 profiles. + +Proves the emulator honours the contracted EMULATOR_* configuration knobs and that +every supported configuration stays internally + behaviourally consistent — the +same invariants a real i3 would satisfy. Two layers: + +- builder invariants over every named profile (no server needed); +- live reflection: boot the app under a non-default profile and confirm /aml + reports the selected shape (the aml_state repoint end-to-end). +""" + +from __future__ import annotations + +import re +from collections.abc import Iterator + +import pytest +from fastapi.testclient import TestClient + +from openblade.api.main import app +from openblade.bootstrap import create_context, reset_context +from openblade.config import OpenBladeConfig +from openblade.simulator.i3_config import ( + _CAPACITY_BY_GENERATION, + NAMED_PROFILES, + ScalarI3Profile, + build_scalar_i3_config, +) + +pytestmark = pytest.mark.i3 + +_COORD_RE = re.compile(r"\A1,[12],\d+\Z") + +_EMU_ENV = ( + "EMULATOR_PROFILE", + "EMULATOR_SLOT_COUNT", + "EMULATOR_DRIVE_COUNT", + "EMULATOR_OCCUPANCY_PERCENT", +) + + +# --------------------------------------------------------------------------- # +# Builder invariants — every named profile must be internally consistent. +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize("name", sorted(NAMED_PROFILES)) +def test_named_profile_is_internally_consistent(name: str) -> None: + profile = NAMED_PROFILES[name] + config = build_scalar_i3_config(profile) + + data = [m for m in config["media"] if m["role"] == "data"] + cleaning = [m for m in config["media"] if m["role"] == "cleaning"] + + # 1. Slot totals and drive counts match the profile; drives within the i3 max. + assert config["partition"]["slotCount"] == profile.slot_count + assert len(config["drives"]) == profile.drive_count + assert 1 <= profile.drive_count <= 6 + + # 2. Addressing: every slot/drive coordinate is a well-formed 1,bay,slot; unique. + slot_coords = [m["slotAddress"] for m in config["media"]] + assert all(_COORD_RE.match(c) for c in slot_coords) + assert all(_COORD_RE.match(d["location"]) for d in config["drives"]) + assert len(slot_coords) == len(set(slot_coords)) # no two cartridges share a slot + + # 3. Exactly one cleaning slot per drive, at the top-most slot ids. + assert len(cleaning) == profile.drive_count + cleaning_slot_ids = sorted(int(m["mockSlotId"]) for m in cleaning) + assert cleaning_slot_ids == list( + range(profile.slot_count - profile.drive_count + 1, profile.slot_count + 1) + ) + for item in cleaning: + assert str(item["type"]).endswith("-CLN") + + # 4. Data-tape count follows occupancy; occupied + empty == total. + fillable = profile.slot_count - profile.drive_count + assert len(data) == round(profile.occupancy_percent / 100 * fillable) + occupied = len(data) + len(cleaning) + assert occupied <= profile.slot_count + + # 5. Each cartridge capacity matches its LTO generation (incl. LTO-9 = 18TB). + for item in data: + assert item["capacityBytes"] == _CAPACITY_BY_GENERATION[item["type"]] + + # 6. Data media generations are all readable by some drive in the library. + drive_generations = {str(d["type"]) for d in config["drives"]} + assert {str(m["type"]) for m in data} <= drive_generations + + # 7. Partition bookkeeping. + if profile.partition_count > 1: + partitions = config["partitions"] + assert len(partitions) == profile.partition_count + assert sum(p["slotCount"] for p in partitions) == profile.slot_count + else: + assert "partitions" not in config + assert config["partition"]["ieSlotCount"] == profile.ie_slot_count + + +@pytest.mark.parametrize( + "mix,slot_count,occupancy,partitions", + [ + (("LTO-8",) * 7, 50, 60, 1), # > 6 drives (exceeds i3 max) + ((), 50, 60, 1), # 0 drives + (("LTO-7", "LTO-8"), 50, 101, 1), # occupancy out of range + (("LTO-7", "LTO-8"), 2, 60, 1), # slot_count < drive_count + 1 + (("LTO-6", "LTO-8"), 50, 60, 1), # unknown generation + (("LTO-7", "LTO-8"), 50, 60, 9), # partitions > drives + ], +) +def test_invalid_profile_fails_fast(mix, slot_count, occupancy, partitions) -> None: + profile = ScalarI3Profile( + profile_name="invalid", + slot_count=slot_count, + drive_generation_mix=mix, + occupancy_percent=occupancy, + partition_count=partitions, + ) + with pytest.raises(ValueError): + build_scalar_i3_config(profile) + + +# --------------------------------------------------------------------------- # +# Live reflection — the running app must report the env-selected shape. +# --------------------------------------------------------------------------- # + +def _client_for_profile(tmp_path) -> TestClient: + # The mock robotics backend is built at context creation, so the env must be set + # before create_context(); aml_state reads it per-request. + reset_context( + create_context(OpenBladeConfig(db_url=f"sqlite:///{tmp_path / 'profile.db'}")) + ) + return TestClient(app) + + +@pytest.fixture +def _restore_default_context() -> Iterator[None]: + yield + reset_context(create_context()) + + +@pytest.mark.parametrize("profile_name", ["scalar-i3-25-1", "scalar-i3-50-6"]) +def test_running_app_reflects_selected_profile( + profile_name: str, + tmp_path, + monkeypatch: pytest.MonkeyPatch, + _restore_default_context: None, +) -> None: + for name in _EMU_ENV: + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("EMULATOR_PROFILE", profile_name) + expected = build_scalar_i3_config(NAMED_PROFILES[profile_name]) + expected_slots = expected["partition"]["slotCount"] + expected_drive_count = len(expected["drives"]) + + client = _client_for_profile(tmp_path) + login = client.post("/aml/users/login", json={"name": "admin", "password": "password"}) + assert login.status_code == 200 + auth = {"Cookie": f"sessionID={login.cookies.get('sessionID')}"} + + library = client.get("/aml/library", headers=auth).json()["library"] + assert library["slotsTotal"] == expected_slots + + inventory = client.get("/aml/library/inventory", headers=auth).json() + assert len(inventory.get("slots") or []) == expected_slots + assert len(inventory.get("drives") or []) == expected_drive_count + + drives_payload = client.get("/aml/drives", headers=auth).json() + drives = drives_payload.get("drives") or (drives_payload.get("driveList") or {}).get("drive") or [] + assert len(drives) == expected_drive_count From dfef9712a7235f4c1b94ef3dd463823d4f040140 Mon Sep 17 00:00:00 2001 From: Amantux Date: Mon, 20 Jul 2026 21:27:52 +0000 Subject: [PATCH 5/6] CI: test the emulator across supported i3 configurations Add an i3-config- matrix job to i3-emulator-compliance.yml that boots the emulator once per named profile (scalar-i3-25-1, -50-3, -100-3, -50-6, -50-3-lto9, -50-4-p2) with EMULATOR_PROFILE set (server + pytest both observe the shape) and runs the i3 core suite + test_18 against each, failing the build if any configuration is non-compliant. The existing suite=core/operations/system matrix is preserved. Add test_18_config_profiles to the emulator-change-gates pre-boot gate, and document in emulator_contract/README.md that the in-repo app now honors the EMULATOR_* shape knobs (default scalar-i3-50-3; >6 drives fails fast) so the emulated i3 behaves like a real i3 across configurations. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012K5Zjc2bggcyJtFbz2D4Pw --- .github/workflows/emulator-change-gates.yml | 1 + .github/workflows/i3-emulator-compliance.yml | 70 ++++++++++++++++++++ openblade/emulator_contract/README.md | 14 ++++ 3 files changed, 85 insertions(+) diff --git a/.github/workflows/emulator-change-gates.yml b/.github/workflows/emulator-change-gates.yml index 5c0b7e1..cf879a1 100644 --- a/.github/workflows/emulator-change-gates.yml +++ b/.github/workflows/emulator-change-gates.yml @@ -69,6 +69,7 @@ jobs: - run: > python -m pytest tests/i3/test_14_emulator_profile.py + tests/i3/test_18_config_profiles.py tests/i3/test_15_manual_matrix_contract.py tests/i3/test_16_manual_matrix_generated_suites.py tests/i3/test_17_matrix_endpoint_coverage.py diff --git a/.github/workflows/i3-emulator-compliance.yml b/.github/workflows/i3-emulator-compliance.yml index 1807890..63ab2f2 100644 --- a/.github/workflows/i3-emulator-compliance.yml +++ b/.github/workflows/i3-emulator-compliance.yml @@ -105,6 +105,76 @@ jobs: test-results/i3-${{ matrix.suite }}.xml /tmp/openblade-api.log + i3-config-profiles: + # Boot the emulator once per supported i3 configuration and assert it behaves + # compliantly for that shape — the emulated i3 must behave like a real i3 across + # the range of valid configurations (drive count, LTO generation, slot/module + # scaling, partition count). EMULATOR_PROFILE is set at job level so both the + # booted server and the pytest process observe the same shape. + name: i3-config-${{ matrix.profile }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + profile: + - scalar-i3-25-1 + - scalar-i3-50-3 + - scalar-i3-100-3 + - scalar-i3-50-6 + - scalar-i3-50-3-lto9 + - scalar-i3-50-4-p2 + env: + I3_TEST_MODE: emulator + I3_TIMING_PROFILE: instant + I3_AML_URL: http://127.0.0.1:8000 + EMULATOR_PROFILE: ${{ matrix.profile }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + - run: pip install -e '.[dev]' + - run: python3 tools/emulator_spec/generate_matrix_endpoint_catalog.py + - run: | + rm -f /tmp/openblade-i3-profile.db + OPENBLADE_BACKEND=mock OPENBLADE_DB_URL=sqlite:////tmp/openblade-i3-profile.db \ + nohup uvicorn openblade.api.main:app --host 127.0.0.1 --port 8000 >/tmp/openblade-api.log 2>&1 & + - name: Wait for API health + run: | + python - <<'PY' + import time + import urllib.request + for _ in range(60): + try: + with urllib.request.urlopen("http://127.0.0.1:8000/health", timeout=2) as response: + if response.status == 200: + raise SystemExit(0) + except Exception: + time.sleep(1) + raise SystemExit("API did not become healthy in time") + PY + - run: mkdir -p test-results + - name: Run the i3 suite against the ${{ matrix.profile }} emulator + run: > + pytest + tests/i3/test_01_auth.py + tests/i3/test_02_inventory.py + tests/i3/test_03_changer.py + tests/i3/test_04_drives.py + tests/i3/test_05_media.py + tests/i3/test_14_emulator_profile.py + tests/i3/test_18_config_profiles.py + -q --tb=short --junitxml=test-results/i3-config-${{ matrix.profile }}.xml + - name: Upload i3 config-profile artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: i3-config-profile-${{ matrix.profile }} + path: | + test-results/i3-config-${{ matrix.profile }}.xml + /tmp/openblade-api.log + aml-compatibility-suite: name: aml-compatibility runs-on: ubuntu-latest diff --git a/openblade/emulator_contract/README.md b/openblade/emulator_contract/README.md index dc74082..3364a2a 100644 --- a/openblade/emulator_contract/README.md +++ b/openblade/emulator_contract/README.md @@ -60,6 +60,17 @@ make emulator-ps `EMULATOR_OCCUPANCY_PERCENT`, `EMULATOR_LATENCY_PROFILE`, and `OPENBLADE_SCALAR_API_ONLY` (defaults to `true` in standalone compose). +The **in-repo** app (`openblade.api.main:app`, backend=mock) now honors the shape +knobs too, not just the separate published image: `EMULATOR_PROFILE` (a named profile +such as `scalar-i3-50-3`, or the parsed `scalar-i3--` form), +`EMULATOR_SLOT_COUNT`, `EMULATOR_DRIVE_COUNT`, and `EMULATOR_OCCUPANCY_PERCENT` +(per-field overrides). The shape is built by `openblade/simulator/i3_config.py` +(`scalar_i3_active_config()`); an unset environment yields the canonical +`scalar-i3-50-3` default, and an invalid configuration (e.g. more than 6 drives — the +documented Scalar i3 maximum) fails fast at build time. So the emulated i3 behaves +like a real i3 across the range of supported configurations, and the same `tests/i3` +suite validates every shape. + Standalone UI (`deploy/emulator/ui/*`, served by `emulator-ui`): - Bind env: `EMULATOR_UI_BIND_HOST` (default `0.0.0.0`) - Port env: `EMULATOR_UI_PORT` (default `5174`) @@ -140,6 +151,9 @@ in pull requests and pushes to `master`, and also support manual dispatch: - Validates cross-repo contract metadata. - Runs sharded i3 emulator suites (`core`, `operations`, `system`) that include matrix contract/generated suite gates. + - Runs the `i3-config-` matrix: boots the emulator once per supported + configuration (`scalar-i3-25-1`, `-50-3`, `-100-3`, `-50-6`, `-50-3-lto9`, + `-50-4-p2`) and runs the i3 suite against each, so every shape stays compliant. ## Compatibility policy From 5248d8508472466e792a963ba6b531743c2a41d4 Mon Sep 17 00:00:00 2001 From: Amantux Date: Mon, 20 Jul 2026 21:52:56 +0000 Subject: [PATCH 6/6] Address review: fix default-profile drift, phantom seeding, env errors; defer partitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer (adversarial) found two proven blockers and three smaller issues: - BLOCKER: EMULATOR_PROFILE=scalar-i3-50-3 dropped the canonical drive_overrides and regenerated drive metadata (load counts drifted) — selecting the default profile by name no longer reproduced the default. Fixed: a named profile with no per-field override is returned verbatim (drive_overrides preserved). Byte-identical again. - BLOCKER: the 2-partition profile orphaned partition-2 media from the AML layer (media counts 28->25, single partition served) because the parity-gated route serializer (routes_aml_partitions._partition_slots) assumes one partition. Rather than rework a parity-gated surface under this change, the multi-partition axis is DEFERRED: reverted the aml_state partition builder to its original single-partition form (kept the scalar_i3_active_config rename) and removed all multi-partition code from the i3_config builder. The compliance matrix now ships the three fully-working axes (drive count, LTO generation, slot/module scaling); multi-partition is documented as awaiting AML route-serializer work. - Clear fail-fast messages naming the offending EMULATOR_* variable on non-integer input. - Demo NAS seeding now guards on the ACTIVE backend's barcodes (identical to default for every shipped profile), so a low-occupancy override can't seed phantom cartridges. - test_18 fixture ordering fixed so the default context is restored after monkeypatch reverts. Added regression tests: named-default reproduces the default; non-integer knob names the variable. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012K5Zjc2bggcyJtFbz2D4Pw --- .github/workflows/i3-emulator-compliance.yml | 1 - .../__pycache__/__init__.cpython-310.pyc | Bin 309 -> 378 bytes openblade/__pycache__/config.cpython-310.pyc | Bin 2642 -> 5855 bytes .../api/__pycache__/__init__.cpython-310.pyc | Bin 175 -> 244 bytes openblade/bootstrap.py | 12 ++- .../__pycache__/__init__.cpython-310.pyc | Bin 210 -> 279 bytes .../catalog/__pycache__/db.cpython-310.pyc | Bin 11286 -> 11355 bytes .../__pycache__/models.cpython-310.pyc | Bin 19581 -> 19650 bytes .../__pycache__/repository.cpython-310.pyc | Bin 54364 -> 54496 bytes .../__pycache__/__init__.cpython-310.pyc | Bin 179 -> 248 bytes .../__pycache__/backends.cpython-310.pyc | Bin 2828 -> 4073 bytes .../domain/__pycache__/errors.cpython-310.pyc | Bin 3635 -> 3704 bytes .../domain/__pycache__/models.cpython-310.pyc | Bin 7286 -> 7355 bytes .../__pycache__/policies.cpython-310.pyc | Bin 3283 -> 3352 bytes .../domain/__pycache__/states.cpython-310.pyc | Bin 2098 -> 2167 bytes openblade/emulator_contract/README.md | 7 +- .../__pycache__/__init__.cpython-310.pyc | Bin 201 -> 270 bytes .../__pycache__/discovery.cpython-310.pyc | Bin 3936 -> 4005 bytes .../hardware/__pycache__/ltfs.cpython-310.pyc | Bin 11798 -> 11867 bytes .../hardware/__pycache__/mtx.cpython-310.pyc | Bin 6369 -> 6438 bytes .../__pycache__/runner.cpython-310.pyc | Bin 3126 -> 3195 bytes .../__pycache__/safety.cpython-310.pyc | Bin 914 -> 983 bytes .../jobs/__pycache__/__init__.cpython-310.pyc | Bin 188 -> 257 bytes .../jobs/__pycache__/format.cpython-310.pyc | Bin 3583 -> 3597 bytes .../__pycache__/inventory.cpython-310.pyc | Bin 1669 -> 1732 bytes .../jobs/__pycache__/queue.cpython-310.pyc | Bin 3663 -> 3732 bytes .../jobs/__pycache__/restore.cpython-310.pyc | Bin 5926 -> 5940 bytes .../jobs/__pycache__/verify.cpython-310.pyc | Bin 2709 -> 2723 bytes .../jobs/__pycache__/worker.cpython-310.pyc | Bin 907 -> 976 bytes .../__pycache__/__init__.cpython-310.pyc | Bin 171 -> 240 bytes .../__pycache__/changer.cpython-310.pyc | Bin 507 -> 576 bytes .../__pycache__/drive.cpython-310.pyc | Bin 662 -> 731 bytes .../__pycache__/faults.cpython-310.pyc | Bin 2901 -> 2970 bytes .../__pycache__/library.cpython-310.pyc | Bin 17497 -> 17578 bytes .../__pycache__/ltfs_volume.cpython-310.pyc | Bin 15634 -> 15715 bytes openblade/simulator/i3_config.py | 80 ++++++------------ tests/i3/test_18_config_profiles.py | 28 +++--- tests/unit/test_i3_config_builder.py | 16 +++- 38 files changed, 63 insertions(+), 81 deletions(-) diff --git a/.github/workflows/i3-emulator-compliance.yml b/.github/workflows/i3-emulator-compliance.yml index 63ab2f2..1625bf1 100644 --- a/.github/workflows/i3-emulator-compliance.yml +++ b/.github/workflows/i3-emulator-compliance.yml @@ -122,7 +122,6 @@ jobs: - scalar-i3-100-3 - scalar-i3-50-6 - scalar-i3-50-3-lto9 - - scalar-i3-50-4-p2 env: I3_TEST_MODE: emulator I3_TIMING_PROFILE: instant diff --git a/openblade/__pycache__/__init__.cpython-310.pyc b/openblade/__pycache__/__init__.cpython-310.pyc index 49e4330ec39770cf1f11a6bccfa4f4371e8f9c73..d8cc7e1c5824e2b15e700cbaa13b31d1689c9ac3 100644 GIT binary patch delta 109 zcmdnW^oxl*pO=@50SKll#7^XX9h#+Il3SpkoRe6ZlB#QYigNdYM5q_YHDU_u3wy7lvt9SQIMFTUtXe{ LUr;=mjZq%}>AfI^ delta 40 ucmeyxw3UfFpO=@50SH7_@l51?EugAjl%HRs?_ZFb=aiF}k~-O&Q6B)<%nNe> diff --git a/openblade/__pycache__/config.cpython-310.pyc b/openblade/__pycache__/config.cpython-310.pyc index 608ed7db6e8dc201d9d9d6bd343391402fabe92b..df52a004bc3ff24dba989ef5a21dc67f46265f9c 100644 GIT binary patch literal 5855 zcmcIoNpl;=6`pAf20I9XBt(f4MeTwl5~L+rOB6*Q2}+D1fB_&Wg3F#7qK6`=!3^9p zpiCB3<&<>s!KqZ`mZeIpob-os=&Mhz>~U5mw)0-kU=c;9atTm)c>VfKFJHf{$4(@o z5_~Rw`r*dpm?V9UjloY48h0c~c76>EmFg0eDKX_1sS_FbfElO<;9oJ7R>HS zekIpaMvx}y&}T9{k)p%!#PAF9Laqa3!&`=6i zr-lD3y1U-MCk8(C>~b*KbT;nY(;#`i7xjo4$I3UY%Q;HLkvU<66#X z09uXpwoY@;+-bY*gT?RMR>XoD9mlm> zJk+!tSGQpG>pG*)bY}1f9%$2Xn~fUSiV8-zZCG!?6M#|fphvdBZo1a;Kr#XwNY?+p9el$KO*=Gz+bFSh%qn0 zx{lV5iE*YuK2x6*Iys?pTwQZA+;aO_kqty%xqXpfIIv;`g8emDey?D>SX6PypfH8BGu4)}-a%H)+ zROS)WexjMireX46LvO5ug@`|mHW?3r^*m`>Pk78{JSj84y1TmfZnMORaSr z0|Er_TH9`dL^0hphYun3<>>#-R1!QVfC?iU_NwMKTZY|nHOByNrp_0)(2fr^)^+QN z!S?qyFM(O|G0K{o#{NE@>H$L8mc}|(X*&x0p4yoz*_OeR-7%@L*=!hIgc@=?ZPR!k z)GkfU%*=ei6W-XKj>8qF;WT-g*{iVfhNHPmx16@kTpo46S#>554eGSjv;Zt$_eJ1kZm-L9Y<+GTP!T) z9~5iaz4HA6JB8|Jkeozv4oMctMI_Tma!BToTt;#Q$yFrpBDs#_CX(+U`7V<0A-MyD zXL_?0?msBzYvqdepi-=|JZj7%SwOOg1j8%KASoc}WuJivd=xq+;pgBYqEeE?<%}Gc zM|<4POyE3zZ)%FoFTp;8Bu$&Or=cF3#1uJlTQ8b#ieAR3U87u4lBPHc+@whXXG zmvs!t%EWef$gdfA;ECb#Am*z!51RHfgJlEkJ(!B&(s+RB&jw(@RKz0?G&VI1CWyQf z1`>yysmLjrJ%R?i2BZPM-iH8(IaBNdf%o=-727U(F6~ITdMDv3Rmly~09Bxp3L=Fb zm8!e)B4hyky^x>FJ97I3YGy+veyF@sD9so1iv{h?!@doZ^q>S@!X;pua}Y`d{6{eO z2T>f3ksbLJl#sjfLqPo_C~y$KJvoQflF9@&S;EQ8EQUFYHlcv>LO|b4c;wa-!!kZ@ zv)ju!3&bgje421?KthAdFR07_-V1`vq3V7e3#`iy`d5vp|gZ1BG$QYhu;i-pA+L(@F~7r!3@ zL*G(_qaP42`M2%UPc~?q4#tA02$0MI_Kp2v-0N8ZM*6N5V65myIgA-J0DDfm!3`DS zpF|b30ceFVh9JdVAMM-0vKJ;6%J*0DHSKQrES$!b|7v(mmf)1TmfXZCS(Y5vg9fo>T~(AWXaFE&q3Vv z5T?PWUDl4A$gAe#n=P3;E%m{D?1~5b~ov`7vKUDdfj{@+n__VsKZm zj+4Ip6hxJuQ3)iccLG4q`0} PBd=*%WdT$j|%oEa-nO^e_05i}aFM@wDHbL4@Fm zoSUGtbdJ9B8MZFF$rm`z6?aJdUv*RB|6S0!Mz8x?H{4;A{up8y=>DH)$bP59hx?u} zpI>+nZlDYkIX~W)ym#-{7%2y!2dO?B**K`wV)D^VGZmj~mNU#C;7pZCr7?G-C z-Bjfz*};^>J_IGE0qLeJb)dTLr&f`*(D(<5D76^-#<%b2R z4IeHq6simh1cr_#3Rh95io%qq`T#7>W97)EQ0oK|EL!|V$(+=CgHE9@+GV~xwl`cGGD`Mb-dyFvkrJ5m1*DpU$ps9wDhE}#OI#r$x8gko)} z3Zd*lsisxRE~7H}CIh8`D4|pZS_&awj|a$CNrn776D0r2sAMk_BLB>U z$v-j?^7mwvd_w-F#K>PWaq^c`f_#}wl0PSh$e-dV@<(-;{6S5V->W0!ckxm3TXjtN z4f!8**r%{4D3QcPL7;%Q1#yvK#V!YKEqJ^Rba~Y6@>qp;dAx{qdAu)W$9YK8sNL}1 z9jRoJ=bsMf< z;sCMAWlbbFsdsxh;1tDn2hz7VGI3hrTi$EU+ZYNRbaO?DlQ3Ko@tY2$gYh7|Pz;6x RDLFM64Mh{tXjDx`{sX9Mm4N^N delta 1264 zcmZuxTW`}y6rLF;X%ag(r%g)(v|NR0anNdcK%h!#VhSQ8&=w?$kQF*Z2?RSDJLRHE zK`MxsWhLYX9uQJ1?LXM}{So^(FZ(Ol2OfYq6OqyikLL5ycg~zMKJ$(LJNNH+*3V?p zNIrkWOOH>b-e(WvSMTR;{mxQ5#xA0%GFr>9I89Ui9bQ7zpqbZ*X12*TUdz$~&C&ep z*jkQiE66gV1b&Ny=ug~P$;ylz;LC{$_x-Ta>M?Jz+or7IOY4EM)_w0`XT$Ej;0YP2 z%Nc-?L@}JiihG&1N8!;2?G*VRMgM4@r;f^UCq-_Qw5Brg#NST*E*?PLm$|Ut>#}=6 z$meHm+rBR{ly^3nOW9^;odwn;PsuP2-_D`5s>cnmObH_Ax^2(%!*w^GlK_VSj0Q1(8>Wn74BRe1JGNMpM+jzv8iYxBp#uYcQk1Hu|avwKEFUEGsjwS^ZfAXW?prS)I$D*(>mk_a#_gVB;Vc~*>n-&&vd5)c)MFg?W zi^T8@e`*_aOr$PvFpoX&@dZ1|o2`LBU+}!h@71U;y0@1bd_o5E609ih`5LVyx8dAz z8vM`!Ppw+J>bUi5%e)MpiM=%|tJS4z%_ZeKa=^Fj=qszO<>A7as8pQ6C!$NmnaC|p z4=i12OiAh|4GrZZ6Ajg)gtK2MmLZ5Ah%x4EcDU~eZG(j(-u0g`uJkL?ciP=Pb2#_8 z(z1xFYasjA>vlpVA#M+#1PIoO3H?EP{R#7^D;qo%{upQNuKVcOL20TU>s`N1-QmRr zO$W9F6BBI6k;dd(NlRjrB)^gbk!(q_`G0 A>Hq)$ diff --git a/openblade/api/__pycache__/__init__.cpython-310.pyc b/openblade/api/__pycache__/__init__.cpython-310.pyc index 69283c9b2ae3ca59dcad7fda367430a257d8b985..0722d4f0c64ce104bc5488f4a7c53f7d942f8850 100644 GIT binary patch delta 108 zcmZ3__=S->pO=@50SKll#7^W+4b9Up$t}=N&PgmyN!2yb*DcD=FVXccNX>J~NlZ!A zH%YQEwJ=Fb)HN`$u+%j%G&a|@OieV`HMLAJHB2)|H8ry|*Dp>kN-RmvC`e4vFE7!} KFDRbapbr3+zaVV@ delta 39 tcmeyuxSo+apO=@50SG>>;+e>uDxj%fl%HRs?_ZFb=aiF}k~;CIJ^ None: if all(spec["dataset_id"] in existing_dataset_ids for spec in demo_specs): return - data_barcodes = set(scalar_i3_data_barcodes()) + # Seed a demo cartridge only for barcodes the active emulator backend actually + # holds, so a small (low-occupancy) profile doesn't leave the catalog referencing + # cartridges absent from the library. Identical to the default barcode set for the + # default and all shipped profiles (their data tapes are a superset of the demo). + data_barcodes = { + str(item["barcode"]) + for item in scalar_i3_active_config()["media"] + if str(item.get("role", "data")) != "cleaning" + } for spec in demo_specs: volume_group = catalog.create_volume_group(spec["volume_group"]) tape_set = sorted({barcode for _, _, barcode in spec["files"]}) diff --git a/openblade/catalog/__pycache__/__init__.cpython-310.pyc b/openblade/catalog/__pycache__/__init__.cpython-310.pyc index 295a4f8a45abe6a051fe1782c5556cb4276cb987..134a98e004546a633ad723d4579fdd0766854b38 100644 GIT binary patch delta 108 zcmcb_IGu?*pO=@50SKll#7^XH2`$nu$t}=N&PgmyN!2yb*DcD=FVXccNX>J~NlZ!A zH%YQEwJ=Fb)HN`$u+%j%G&a|@OieV`HMLAJHB2)|H8ry|*Dp>kN-RmvC`e4vFE7!} KFDRZk-xvUk@*sKu delta 39 tcmbQvbcvBWpO=@50SG>>;+e?ZBA}~Zl%HRs?_ZFb=aiF}k~;B;F#ysm3_}0_ diff --git a/openblade/catalog/__pycache__/db.cpython-310.pyc b/openblade/catalog/__pycache__/db.cpython-310.pyc index 4b456d21d1f9613d17de81518bd8986d4db8ee4e..c41fb279a9741f5af2ed3da21570f5bce35f7e9a 100644 GIT binary patch delta 111 zcmbOhaXW%LpO=@50SKll#BSui!xWmMUy@s(pPZ9env$w(ps!n$pI@TuUyz#Tl#`f} zs&A5HVQOKLmZ)oBU}33iVrXoxYnhs8u4`(UVrrOXkZNjXX|7+KT$EUnoKcXNqF-L3 Nn_p18`8$)mCICtvA~XO1 delta 42 wcmcZ|F)e~SpO=@50SNT1g*I~EVG>Z+FUrp^(f2P%&2!30OiA5r!z`}}0Q2Yz(*OVf diff --git a/openblade/catalog/__pycache__/models.cpython-310.pyc b/openblade/catalog/__pycache__/models.cpython-310.pyc index 7a415a9ea8d5a92c647fb414a9cfe1cb0f93e6e1..447761565dfc4b8c7b0063bf7c3e6a7d554b30f0 100644 GIT binary patch delta 113 zcmex6gYnQzM(%uGUM>b8n5q!Fk$VYqXn}r7Zh?MsPGV_Fs;+^)Zc%=IiLQS^YMxV0 zVoIvMNs@)Bg-KeXu7QDtrLKvgvAM2gYNENWsbz|(VVXgzshOp@esOY9Vo7pFL1K!2 Pd5La*LGkAO%=!lkx8iM(%uGUM>b8(6<)a$i0MFKwG~kKfgrZzaTZwDJL-{bu$agJ`VsCXAJlN diff --git a/openblade/catalog/__pycache__/repository.cpython-310.pyc b/openblade/catalog/__pycache__/repository.cpython-310.pyc index b4866398b8b0a600d6723261d126f770242f608b..3cb3ff13e18c168511b45e602f0eed423d6af7b1 100644 GIT binary patch delta 1561 zcmZ9KZERCz6vyv*+TC{Ty0NwECX~Gu1C5q$y3xG}0Wo|)A`8w04Rn|GPBz9`ZtoOk zVIMS#64>x8ybOdECs~0B?6PIfWn=omC@hFEA>vC&d=F7){NT%k`JB6gXg~b!x#v0O z|NPIn{i4rsBWbXOZ8nR*e-FO8vH8?_+eliy>QlEye8Co}EhKwOeO@ITR=rO|p@QEN_&&rKOcs-tw}C{oX2B@_Q?)LKS6=rE+ZL9}rz_Q| zd^*pk-_2W;x>VlE^Oh})YiGo5A=0vNZ3Ahq5K=0c4IyJ2C=IEWOkR}PoRtz|@i}BH z0r6~}&*sk@{>-7)-9ocH?ucs-((`6+Z|9W_K#K4~&8kW&BIH zg!XEj7*hNW%42{(bndk+;)P}=f&)ZwX}h}C0(_`l*$)8MwX3ho z05>#$_dJ&QBdM11Vv!lajM88(cJx`WnL1RKY2xGvr;Hd5ZG{S+?-h;k1vuuO)Dmb?s0 zOH)+U#iFg+*%MpACDK)ud=#aM@DYb6U&)}X4`+Jvacyr8wBgO(89vj^x7^UJWu0^| zG3SoO5?tMv4aaap-^*t1P4KYxLQEuGXiAna2g9l%Lz z&c$uXMci{w@|fv;-7~{&+nv}|ZN&AbzK2h6`RTXe5>B0-fLt6nvj9HDYiE8oIq34a zx;B2@p9f!R)BS#y;nF>E56Us1d(l;8#trAJ@U7N#ZZ|N8hy^W0Xdj4!NxLwxnTf@- zQ=A!gXQdXcN)b5_jyUnj!A`i1-wf`s){0z_w%k4+r8=j!YiJKJH^bNk7cLwgf=9Kl zhGPI*@tKi#Obt|A#Q1PdoLZiSu@SSj^xfwGwrPW-tH5fY8&}b)Z=q>y4TQ0NY!Y(N zJ^p}I->IJpDslaIG0T~q5XW=i5S|=ATe*avdCbasY7f(#g%m9y+|x?}S9mUIP&U$0 z4avALc`*Y{;LyeM>=C}@J$8&u*r8wRn($a49Y-#$giM^dv^q~$T0)H#1d2RH#evf0 z+bcl-{?$K{>{$4JDq<1^lY#GUH+$NPzvyu%MU>@mQ1dJ0*p-7di9(2Y3}W= z`LAXz?ZBwiXrE2qgpS{73x5#)B1{u*5d`Yemz_qDiC`vJ2>SoqMo|`F4k4RhCpZat qgnYt%gn5MdghE0I!OiA#E}-y!LIt6ku#~V&YxtM}IJMx_KiNNmxxK*v delta 1320 zcmZ9LYfKzf6vyY>g<+RvmtELpTgqd37_w@*_<#a!O>J8FU>e#G{ZP}AEj!41vmf|KJH`X#<8LyiCK)WM?pB*zqVWY65 zlHfNqf?T##k-&2jYAj1yOttdbn93-qiToguUv2zqqtZ5^#;ztcb2DmkR8SpJVUOW; zp$hyl{@7QH&4x`vE0hZ!arf4z0zpNR-C>_n?XL5M!-4weBdguzjp1-eRwC}OEL8CBh z8I%CN*T(j(Vlamn_um7ieSN?JV8ZJM$IXsdL69dv5O(L|wl)`ja;OsQTHfJjfh_>G z961OnxZ>!KFpmwD$riI;3QCGpA%}vJSIa!MjfqCa1@7R6o*X>XZH6N3>3#$5VZOS{ zw1cz-eva2{4m_jg0pLY-pXjH!L2HO+fw|#-{XcknJ6JIBcn$=$$Bx$l#A5V>6U=z_ z#94@7sAqx2GxXIut-eYn(8R}4(HN2BP7U92FwuVBOg7H;+Tkc(>wS%zsqWj%k{J%R zyYRCn$AXx)^GSNiA77naQx=)>tg8IQjVw89iB zCnv#yg{N}iOMLFs&vD7rc;#Y?n@vUxolb;rwU*Osn7C@ull7$s5`2yRfKnkfN%hKg z?dE%Jz>-9?zUsyU@2e226=~H>bT6jokL2Lgkd-&NG}J-2F#MvaM7%%GU%MUJ{BQ@b zY}PrHh65iC!;{*sk!FBe{9$xN+)0_4TG!ZNfRI-FQ7M=i>6Y@BM81pd<6EE(FOScF z9p63okXavFAtkKAE9bJ9eep(I6G^ZiADlP?1^CTGYTCcjD0h~N^%>-yq*;QtNe`%4 zGC2tA@TbW<7|( zqU|_;16uy%AAtN9;TGXG;Vxl;ppPzw>P12v!9*|<5(rj8BEd#TA~*>t1Q%fiA(fCu n$RK19vRMXa4lQ#Dj}wXs>j_UVZ22k$?_9XSzwNm%Zn3`s(7KP{ diff --git a/openblade/domain/__pycache__/__init__.cpython-310.pyc b/openblade/domain/__pycache__/__init__.cpython-310.pyc index 7d3022fb3f8aa1dbe644a0e267e5df5cc7007180..566ede15211a661b2e731c73a0bc95b8bdd34a2c 100644 GIT binary patch delta 108 zcmdnY_=AxJ~NlZ!A zH%YQEwJ=Fb)HN`$u+%j%G&a|@OieV`HMLAJHB2)|H8ry|*Dp>kN-RmvC`e4vFE7!} KFDRbaXaoS8ejt4S delta 39 tcmeytxS5eVpO=@50SH7_@l51S6VTBw%Fi#+_b*7zbIM6fNu7Ai2mr%03u*uW diff --git a/openblade/domain/__pycache__/backends.cpython-310.pyc b/openblade/domain/__pycache__/backends.cpython-310.pyc index 8a20caecc1ba9064fb4ffa7e0a50044620235134..c25e17e98cb09fe58ac5740476e18fce3b5dcc88 100644 GIT binary patch literal 4073 zcmbtXOLG%P5MJ%Bq}BUn$q!x%kcSdvnP6i(A;g#z302^@;+*!7t<}y5Z`g;JT|w+C zTvC-k!vD!>4!Lm3Iky~0x?2wmA*yUx*S2T6d*|!N%ycU(6tW6D*M9%y*H>vpd4-ed zCkc}sc%wgnFvV0@f~j7@S54K|Of7+P&C~s)neS6GUrUnnfiQ_r>M33GvX2Fn01Gvd`* z_CRUnj_>b_FbBl}7cI<}1PdplUvCJHJVN7tXgsk2Rg>T1q z!=UHl0Ms0GYx+sSWCz~pPas?|6Od7rCAij8naZ>mx~VgrCs`8S1W)x+lnvcX6UUfv zGb{_b2I-z~b1VOAS79BR{`zrd z;FdwJ!i&oa*to=&fm0pn)ka^huyep&U^Unk=Vfse-Cf&x%mXGnTx`KaJUu0evk&kooBhs(cruBgX6Kw zq6QPOfCSxaX<`w_=a8HqbB7UFf~nNK&}O8v0@LH#j2$SIx!4wucuXdo6__wD&H+vh z0$d!85CmPHfgZ}wibsyl!_)onDPNwT%{q!w2a_>=aQv>Is>7hpi)RJ{MOi|phMvtP zccLQGm{m;Y8IdpJ+LLW8D3iuGMHB)G>EcXC#~L!-wS@!s&faN(UyfY9ItO^6=LU>6 zht)l@Mk`Zn$cQU5M$FSbtr_CuIrw#2adL5k8Wi(O6TdIdfu2!r)umxsfei)El3C00LpJbm zoVTne1KS%)1ojaE&yLif9E&1ODZT{O#HoSgBP1mxSAa+@iiP+Dr=Q|f_gr9nhI42~ z6wyL*vWaO#*FgmYc!Nb4YeOljjRgKx;cxhVB_UA9f%lCEjo5)V`U}W(4P+|UnZ}b) z^>mhmiju<0$WlBFTm!giW&k$>eTYGNI?J*gaI(b7^BgPiJV**XgB4i`B*l>=50V^x zV~i?bi6m9hFT>h3wgCDSLezK_YGR!=fKwZB7J#$JmVi^wEVB!21uTQFxwfmYWq38- z!2ht)Id=X<(p+TAPz^829Cj9?QYi3X%DgkxA&4rl&U$^)`MwSxP9=tJmsF2h5R%iz zu1=w^4uul#SjOKA^2QvsH4op?g5Oe2y^rVgo-I_)89jc&1xIcVuYKx@)|CA``+ z-bgA_!*VQj#-rHLVWIJZ<98H{m-?_^5bh@Ei7g~2d!Is_eJ0$PTRrF%@w^OM1NFEu zZN`bpX|cuj9Hs;&?uiZr{w52bAWZcquMB-5SOPhQFPi& z&MgEv9z@XITe02es}qdHX^bh24}z)+j-8zwJc9eTZ#!;$)R`b&oJM?GaZi>;MbZj| z^*-UfgHZFro29&Uk76FJp$;YXjSY07Kuuz6Nze}X0=2Ipp}>`KMA!V6I4U5?BUwjs z6A5-K0y|HULGl%nTS#(9ZX>}HA-+XI8Q+1COpQO z2^IhTo4jiFwW>{KC;?7G!}_4AI_YJBn^KFf1+KT(YRI%@vCx6amd1d4@GEWNfK0_l zeaPg{Ui?)o@W+%~ob>Ul!#;i$uQx9mRxc`ejiY$ zA=L5;1k(bI>C8y=%m|Fk3``ySW@=@%pqANzO?@k^XHMW`Zs2B(ppkijm-&I8HG^i> z3R+n^Xw$e_+R3^>SJ$}xmBwsVztUKp*Pfft^`J*Ohq<70=ej;?oN7bw^5{eqvWSax zkmQn!aTN1%ASaO=oE361D3ffKMzRotC}#t~qjWHd1bY?(JPh)?+?s-TCas*kVoA0*%MoG}8x7hjl^I zWIae$U$y3uxF5w&dCrtAxSWYR*1^3+!8X*tAvhbS(OyCGqFI z15p(600NcYPb0<;3TT~CixpXv6rRKv>%4t$#Z;S`JcPSNIVbDJjiNQiWGo7{UlvrO z3hJhKx)>D&_e4RCIC0tIE#S)}L=8VPv+Mo1SUj)yJfgkc^5 ze}t+LhFQU8DYpGE{9zWQl|?wX5*`j^0=kEoSl>5QW8RaItM96K3-|XdTejnM0J| za)-uQtie3cG=R4q=5voVxeu1+*kLW!21{#h@xkKJ9A{1%ZL;)edz# zIotqQ%kW_spoI{->xg7i_Jp2|Z4dDW8_?>roYs7EiPvApn_HD#k)pB~FJ+7by(rjz z$+f+LUAL+jb|c}JRHNr_!gKef;KMq*4;ah7&vP2mS%+nmxyKivRq!B2s_iv~(*XB`tw$HShW3}6Jr+aQ!#N+Iy>RxCi1 zJ?!G&ns^_@Jrqb}0v{w%NAVGgPf)lhK0~nzIJNL#3k(4ANr@?Ov^}7Qo^Igx_p%y| zzYKHPLkno`m^AlzxTES}$ch+9td7A-;eTi8jI!m$G|8U`JRSmnA=Uo!l^#{E^a!s_ zT2_ok&J+ES-?T~EE^#1Iox1(P0@ET*;)It~oRIOzw@n*X&gT{9eu0Oz#P{0LIz~so NYxoVrguT0a`ahXKP;>wQ diff --git a/openblade/domain/__pycache__/errors.cpython-310.pyc b/openblade/domain/__pycache__/errors.cpython-310.pyc index b1b7fc2635aadef75eb80b5afe284ddba1d670a1..626f6d274ecece94aaaf4a072233e58e2fe2dc80 100644 GIT binary patch delta 111 zcmdli^FxL^pO=@50SKll#BSuaW(v*MFUc*?PtHj!O-a=?(AO=>&o9ySFG$UE%1KN~ z)i+79FtspAOVl+mu&~rMF*G*UwM&o9ySFG$UE%1KN~ z)i+79FtspAOVl+mu&~rMF*G*UwM<~ijgrlfAZ#r#kL0Pf-p#Q*>R diff --git a/openblade/domain/__pycache__/policies.cpython-310.pyc b/openblade/domain/__pycache__/policies.cpython-310.pyc index 8036e463fd2286857c215800c77b0482d3a582a8..45dfd451feb63cb90a68ca0d88a735b383d1d300 100644 GIT binary patch delta 111 zcmcaCIYWv&pO=@50SKll#BSs+WC|_RFUc*?PtHj!O-a=?(AO=>&o9ySFG$UE%1KN~ z)i+79FtspAOVl+mu&~rMF*G*UwM&o9ySFG$UE%1KN~ z)i+79FtspAOVl+mu&~rMF*G*UwM` matrix: boots the emulator once per supported - configuration (`scalar-i3-25-1`, `-50-3`, `-100-3`, `-50-6`, `-50-3-lto9`, - `-50-4-p2`) and runs the i3 suite against each, so every shape stays compliant. + configuration (`scalar-i3-25-1`, `-50-3`, `-100-3`, `-50-6`, `-50-3-lto9` — + covering drive count, slot/module scaling, and LTO generation) and runs the i3 + suite against each, so every shape stays compliant. A multi-partition axis is not + yet shipped: the AML partition route serializer assumes a single partition, so a + 2-partition profile awaits that (parity-gated) route work. ## Compatibility policy diff --git a/openblade/hardware/__pycache__/__init__.cpython-310.pyc b/openblade/hardware/__pycache__/__init__.cpython-310.pyc index 93e24ec08bc4d614cdc06fe0fd8b30d34ea88e67..0573335532cdb0b37d379966785a86119888f55a 100644 GIT binary patch delta 108 zcmX@f*vG`3&&$ij00dJNVkdG}g%<0Vz>lWqbm+1Nzq~KYhWSn8S>8k_4{rY4%}np&oq8m1YfnwnXf>lY^%C6**-6eOnTmzU_~ K7ZgvNY61X(Eg)6^ delta 39 tcmeBUI?2eL&&$ij00g3|cqVdJ3Fzq;<>!~^`xm6E&u=k diff --git a/openblade/hardware/__pycache__/mtx.cpython-310.pyc b/openblade/hardware/__pycache__/mtx.cpython-310.pyc index 55fe4d35b2b537179479887013a766adb4da980a..038978b5dd824ccb8a4a99b95fa5c80082fe4865 100644 GIT binary patch delta 111 zcmaE8xXg$&o9ySFG$UE%1KN~ z)i+79FtspAOVl+mu&~rMF*G*UwM&o9ySFG$UE%1KN~ z)i+79FtspAOVl+mu&~rMF*G*UwM&o9ySFG$UE%1KN~ z)i+79FtspAOVl+mu&~rMF*G*UwMpO=@50SH7_@oeO7XA;oSFUrp^(f2P%&2!30Oi7*moaqAq(RmD7 diff --git a/openblade/jobs/__pycache__/__init__.cpython-310.pyc b/openblade/jobs/__pycache__/__init__.cpython-310.pyc index ea21fa1cb50d30daee39d04f5c0a4ffde504c11f..196b35609f7e6b6d21e0d832378f5ad08533708b 100644 GIT binary patch delta 108 zcmdnP*vQ14&&$ij00dJNVkdGJhUV*+z>lWqbm+1Nzq~KYhWSn8S>8k_4{rY4%}np&oq8m1YfnwnXf>lY^%C6**-6eOnTmzU_~ K7ZgwIF#rH@uOK1- delta 39 tcmZo<+QZ15&&$ij00bXb@l50{6wuNy%Fi#+_b*7zbIM6fNu7An007Ce3+n&? diff --git a/openblade/jobs/__pycache__/format.cpython-310.pyc b/openblade/jobs/__pycache__/format.cpython-310.pyc index ac77fb107ba60babd62d3490242e408cbe09e0f6..92d947d322d60313f0cf781aaac71062f9f4bac5 100644 GIT binary patch delta 623 zcmZvYJ#W)M7{~oMiQ|{ERhgrs`TG9D zwV7Ince7O+2eKmrhXD(|5+DaH>a4E@nxh3dCl~0BzStA}l_2lr7q#RUf}&FlN=^y5 zEe^|AIpnbNl6Ou>2v>0pSMaIBjB^sB9j;NIeI3@c?Y&{-Mh6@2=#e+Ub7gyXbLY>3 znDCK^{60!kd-JrPjN>RnUh&WF24)gW&5`du#-3rBMihpLF_?Oj4d2C{*%|h`{m$4m zEURxDo%WTUVS8@R=-Rm39$Q}bs@*f=QRF702U8cDPZJ}Y#zak>gWOR&g#oJMZTdN7 zDf3+r=k=sSFy$T-Y4kxwXC&64GpjESnY%19xxI8ZPsWmp(G&7}b{_&601ZPCfrXXB3IaES^)y vdG*3+W^P{it2J@sR&p@);)P@KLA_in)0Q-cc*N_V1A-!*I%p}rumAiF>YlWcy~=0|Hmlm;<@qF|wD4bmTA3EJRc50-6aBWq`NVtHjDyIoa#9!m3s^vGcUX zW~>=d))it9bfggUB4wS?F=>%G(jd*FtTjvUw$h$|a(Q5norRGfFguD|@BYH0$(KIg zgb(dD#N{o2xOdCl4ea2sXAk!rj|jDxsDrib6N&$XhX{){;tY*OVNl><>an@l4SYZ9 z_Q#Ibqc(B)YWf_Q$0SVvTKse7ncznDo0<`UfzDUd3y|YObrl-?z1j!$_=kEEAj_Av zHfVgPy@5;ow|3W%iGs33f$!+oLFI4t+fd}+^=?g*a!KNUuPgaQaoxTA0nEzR4h#t- z5UXqbJ6ksffy%!cedzHOw9z^B4jExetV>iRRDpJ#e?-@K4cEDWTT9F*4h@r&xbBO_ zcV*)p;xpUzI%A)@L)Qs|oD5IW==>=@Kaxeom8rjHhn?tfEIg-`HmAp-%l4@q`9X*N t`J1D0)ncTaz#)`r>>g6{sKytr0xI! diff --git a/openblade/jobs/__pycache__/inventory.cpython-310.pyc b/openblade/jobs/__pycache__/inventory.cpython-310.pyc index e55175d3327020ea6d8957c705e9af2292362eef..7e5e7a72b0c06531ee43a9c620c491686b706071 100644 GIT binary patch delta 148 zcmZqWJ;KYI&&$ij00cUnaaofm@-AoOo4E5}Xn}r7Zh?MsPGV_Fs;+^)Zc%=IiLQS^ zYMxV0VoIvMNs@)Bg-KeXu7QDtrLKvgvAM2gYNENWsbz|(VVXgzshOp@esOY9Vo7pF wL1K!2d5La*LGk7;#vGewp=pO=@50SKll#BSs^XAaHLFUc*?PtHj!O-a=?(AO=>&o9ySFG$UE%1KN~ z)i+79FtspAOVl+mu&~rMF*G*UwM2>0Wb$m2CQZ|aZLO0@P0}Pj5Q|R?tq_Ya3vrS&#p+BFCn>Ef zDec0?svdRkMqJbfL9^UVM~#>gvsuswjJOiF*{dgvL1oZLDoNlp z*9i`NC~j~lt~=I28L~5e46sbV&ZIDpVT^3Jlwq8~D8?9#U;*QpU^I$FEDd3b;TV>1 z1VbT99 z&mOP*4JhomL*Jb-`sR4OP?y_AQ?BXiGHOyrmdr+@EzLKz`mC;^Cg-Zfd@;AEN|{Wt zB;}^F1*xQ|1u0*`{Pbc*%g>Ywa;s*l?b^kritMG=$=nTjBOfKd=NMm|hXNoGR|cSs5|o%-6M#04P` zHpT^B^(Pn;HJa4u0^RxxTy=wqTN4&;HSxWX1TfAd=bm@&nfo&5zSm#qvLnka9{!Xb z2NvIK$w?UA>RDR_4+8qFs2ww6E|;vMcB|3qa@lIL<3`+07zy_ru#$GlNV#0G(ssMi z4j$8Y-GeFyH#`_z_pXDHag`8;xe|7j4jjZN#x{J$F+7WH80V}L&tVcrQk-_-dCXu3 zXUFjZc48N2CvXUL%yHI@!`O$XIm_~k`f-4d9a#4oC-Ka*Cwq|)UQ^DymCZsuG~vuI zjTh!vo~?}L=aGOD!HkWSsbwV{?9po)N*Dj+Bz zL{I&baJTl|Khpp$G^AVtnI0;7@_vSU?klSTL(rU}Rh=oQe%fwrR~nL$o`9?2_tRZxLfj=Z|BTPYrE{R0Ff+!z1= diff --git a/openblade/jobs/__pycache__/verify.cpython-310.pyc b/openblade/jobs/__pycache__/verify.cpython-310.pyc index a3c127e8dc1289172970ffc89891f6afeb802886..fcc79aac9da7b2659dc88398fbe54cadaa92070d 100644 GIT binary patch delta 503 zcmbO#x>%GqpO=@50SI(DSz z34+;NIYPO@QNp<*Q6fOTTnc}RKnqKhXo`G_WQr7!C6=O)B9kHuWQnIR2Qz3YPP|kh z$8?L&Co`!iv8d81F*!RmFXa}uPl#JElr!0r(P{D)#+RYF`X#vq`pG$or75Yp2Ku^1 z`S~Tf{spOdPC1Dwsrn{K7N!;^X^FZ91{RjOCWgl5x|XSl=DMbqDW-;L2C1fImgf4! z$wi4J$r%NSDf;Cly7>jglh-mSGYU_>$kfLuFgbzQoQECg>02y0C27S)9Ge$2J1}yC z!l8&8MDR?0$KuH-vDt$4A0s2@WOMd@6v>P1xs0Zh?Kyh6L5375g9whv*Ex)(koX`$ zJ`iC9Bs3)^t8kXuOXdT8kp%LEUP^v$VrHIR5+opsi@-WLfJ$$PV<^haPf5)wF5;ZL znNw8`6Dn9otl>d6y*nthPVYog(fdzd?}!zUzDF; zqVHdjn&*_0n36jA0h2PL$Yd7gK1RXGlbOwVxPe}{#gbE!R$Roh`5LnWBR2<7q=*wl za7|WZ^<YF54m|B>mCF&X&SXkzZ1om>Q-Tq?(#pn(G%Q7bTV?XA~r+=$Du1 N<`)!iUds545dhurA$9-& delta 42 wcmcb>-p$UP&&$ij00gI3^K9hqVieHOFUrp^(f2P%&2!30OiA7Rit!mE0Pw;M8UO$Q diff --git a/openblade/simulator/__pycache__/__init__.cpython-310.pyc b/openblade/simulator/__pycache__/__init__.cpython-310.pyc index 65d7a7b129b3b8939013b464ca384504359781c7..b18d9a383524f78689adaa4425a52bc9e85f6f38 100644 GIT binary patch delta 108 zcmZ3@_<@l-pO=@50SKll#7^Xn4lU6y$t}=N&PgmyN!2yb*DcD=FVXccNX>J~NlZ!A zH%YQEwJ=Fb)HN`$u+%j%G&a|@OieV`HMLAJHB2)|H8ry|*Dp>kN-RmvC`e4vFE7!} KFDRZ^W(ok2&o9ySFG$UE%1KN~ z)i+79FtspAOVl+mu&~rMF*G*UwMb%7 delta 41 vcmcc3I*pY(pO=@50SH7_@oeNi$|#_tUzDF;qVHdjn&*_0n36hKm1!FQ)fo$) diff --git a/openblade/simulator/__pycache__/faults.cpython-310.pyc b/openblade/simulator/__pycache__/faults.cpython-310.pyc index d4b3521ee428c5b89dc8bbcb1fb57ca8290d93f2..dbc94d82c49099918e79abc1a053b2fdd127c2f5 100644 GIT binary patch delta 111 zcmcaAHcOm4pO=@50SKll#BSt1%oJLrUy@s(pPZ9env$w(ps!n$pI@TuUyz#Tl#`f} zs&A5HVQOKLmZ)oBU}33iVrXoxYnhs8u4`(UVrrOXkZNjXX|7+KT$EUnoKcXNqF-L3 Nn_p18`5sd_2LKB!A-Vtn delta 42 wcmbOwepQS+pO=@50SJuN^K9fk%p{*Wf?b@y&6n;Umivmhe{ zGkR$K(uW`^C#qmf_#anm#+IhozH;-=fL;lWz0^(5HT3^ zjCM7Xr~A(*3}%Q4>jji0W)q#&U}Q!v3{{1#Voge$LtMC4tku{GM$zBq726$%c4c=P zBNxfVA# z14l!WtnfmQ+vyep5@)lyJ)B^7xHyj@xj3gscG?3r#aZuh@iD(D#r?feN#=*+TqGK! zH+3)IW0QtfX)qK&Hqfv5;}JD!>go#*h9pg<{1#P>s1_tMQ*?49s#w@$RtkKPfk0nS z709i$Wwg;?db3pqxTI)7H8?g-s8Xo&BzgfY2we_ z#0>G7uHZUdF*$$-gv}qqEFH<0fCcht-x{8iNBc*yOl$|mA)%r+{Nl>y#(BHK#*Z#cN`A zKE)DQb2j1?G1XHz$Wpx*Z>Y{?1>RBKU4g81FpRpPJ?C zOST(a*q|2g9~N7*tTC`NW%5W1w8E=RTSynQsCthq`hut(^b delta 585 zcmW-eT}V@L7{}P%=KLyf%MV1|)Tvu)4aCePQ5b0uod{Z%x!>`hY&HMx|YpC<%4b7eW2?z5D&(f#=~(j^o`WD2AfgByn*y zudgkkIMI>t#8H$;CfYoaO0^P_CTRuXsz|k39dWB}NV@HsSFgDawN~HKCTUL1H704U zQF#<q2R(%S@mghr&LkQ6G)fs)n3yu2EB&{^!}D`NYi=6GKW2ul(H`r3lHw^ z@6)>m4KuZM6OWu7&{Nr+y^&ZrBzYb@Q6mXy}(`W*v>LioK{c(e4-NPFxKd*a~)53+_fyj$9+Ya zz;7Ndo)Sb#=^GUB=hB)D$Sc2t13X_|2Arn#itmWglKThZyyyu47syfd6cfB$H3LjC zdA|WSXsmV(Oh(SL8 j4nh=mwX7oTrihw;Qd)aZL$TJf%pBOQHd(gV;jsP(o)5)j diff --git a/openblade/simulator/__pycache__/ltfs_volume.cpython-310.pyc b/openblade/simulator/__pycache__/ltfs_volume.cpython-310.pyc index 891d898d133bd15d474c2884a0ebd98d8f70c2e8..a0eb08bd6ba7ee0e1c1e4d17aea76c11898a0047 100644 GIT binary patch delta 635 zcmW-f&r1|x7{}-PuDQ6s8h3Ry*AmOp)g5P7_osO&u`nWoO~FBoX=f~#*_~}16Zc@0 zT|@}%y?Ch%47y0t3q@Y+5SVoef?z9Z2@H?@1J*DPJUrjehv&IHkIKKxKHckeaaKi^ zr}B|YtG-r@ul9Vudj_1sDITq(&?$A<*{g*M-BP#3oi_Wloj7ptN*A_}@m{i<04C%$`nVUw4iH;P;=0&qm z5_6h7r>H_y6bxNAh0&5)Jg>=$D#mZ56Y2Q0EJUN}j1Z6Yr-Y0ur-VdCNyMh3Y9g6Q ziL*IFHgmU1vLeo#f?k@XcWMMA*+xx0kY=xsbO7h*x7zoJvUTS%f=Rkv_Y`AHa-{*4 zQuhw7)2wF)ms!9&02J93-vL3HrR!e+E41qWhsU%UxQ8{CYq$?=(*4FN*4c+(6Tu$) z)f@)iGFQuUf-2o=-NZhfI$lADWlp$(FKoCiNKi{B+skmXukB9Ykp4XR7tQSRsYO_O zS5L1wtZ~q#10tK~9w*4Ntw@q!k^SktP4JLS#5RB@^sw(EUb4M@0E~W$@1TXQB+g=+ z?j|-6qFQno@93-K8|<=tYSe)RwmqTDdP0UU|TZ}PYv(zz(t6I2MJl- I&cpBe5Bapg82|tP delta 562 zcmW;IUr1AN6bJD8{cSq-*UinXZI)SP-noBH-7Jf!urMeIBj`d8%X>>@+qKK2k|=6H z2@%*YLgQ+dXg|z8}tk!}&gUf6k-2-44O1 zlgpmzKcfqtcAQw~{d|3Qu}A8GAPcgqAQod%tk^Aeo7r9HEykrd1jYL-p(K|fr4-vy zLH5Wkw*+Y$ zphb5d$URL;qHGu*8ZS?_JYHIER4)B=mJ(Bd(7G{vR-LZsmdf^xXCipjJ z9cTH9tBzxI&b=2X&=b!l!)3bds{->p?Qh@_PX}(`A-xLR1lIUq@GG8DC=_D&K&h|@ ztkapubA~Ux(O$z3UfZf8LU*<~flu_HBgkOoYn^kjQ}+%V@Ruid{z8zpcU=R*w6tr{ zVvG-oDv+RR>@>q9HR4%@tCUS$WVlP!zE$8cKi>ZlHQF)&;3ba?))D3U;4ohEV0IM| z{x&;;cO1EOY|xwBm<5-}K0J@+vhj8;$`|)>WO#gkM=$_ylM4>UEMBmv(VbqjI{pDs CtEOcD diff --git a/openblade/simulator/i3_config.py b/openblade/simulator/i3_config.py index 316e9ff..6eb5d14 100644 --- a/openblade/simulator/i3_config.py +++ b/openblade/simulator/i3_config.py @@ -56,7 +56,6 @@ class ScalarI3Profile: drive_generation_mix: tuple[str, ...] occupancy_percent: int ie_slot_count: int = 2 - partition_count: int = 1 # Optional canonical drive list (used by the default profile to stay # byte-identical to the historical literal). When None, drives are generated. drive_overrides: tuple[dict[str, Any], ...] | None = None @@ -86,13 +85,6 @@ def validate(self) -> None: raise ValueError( f"occupancy_percent must be in 0..100; got {self.occupancy_percent}" ) - if self.partition_count < 1: - raise ValueError(f"partition_count must be >= 1; got {self.partition_count}") - if self.partition_count > self.drive_count: - raise ValueError( - f"partition_count ({self.partition_count}) cannot exceed drive_count " - f"({self.drive_count}); each partition needs at least one drive" - ) if self.ie_slot_count < 0: raise ValueError(f"ie_slot_count must be >= 0; got {self.ie_slot_count}") if self.drive_overrides is not None and len(self.drive_overrides) != self.drive_count: @@ -152,7 +144,6 @@ def validate(self) -> None: # 60% of the 47 non-cleaning slots -> round(28.2) == 28 data tapes (unchanged). occupancy_percent=60, ie_slot_count=2, - partition_count=1, drive_overrides=_DEFAULT_DRIVES, ) @@ -183,13 +174,11 @@ def validate(self) -> None: drive_generation_mix=("LTO-9", "LTO-9", "LTO-9"), occupancy_percent=60, ), - "scalar-i3-50-4-p2": ScalarI3Profile( - profile_name="scalar-i3-50-4-p2", - slot_count=50, - drive_generation_mix=("LTO-7", "LTO-7", "LTO-8", "LTO-8"), - occupancy_percent=60, - partition_count=2, - ), + # NOTE: a multi-partition axis is intentionally NOT shipped yet — the AML partition + # route serializer (routes_aml_partitions._partition_slots) assumes a single + # partition over the absolute slot range, so a 2-partition library cannot be served + # over /aml faithfully until that (parity-gated) route work lands. Every shipped + # profile is single-partition. } @@ -230,14 +219,6 @@ def _tape_generation_boundaries(profile: ScalarI3Profile, data_count: int) -> li return per_slot -def _partition_name_for_slot(slot_id: int, profile: ScalarI3Profile) -> str: - if profile.partition_count == 1: - return "partition1" - per_partition = math.ceil(profile.slot_count / profile.partition_count) - index = min((slot_id - 1) // per_partition, profile.partition_count - 1) - return f"partition{index + 1}" - - def _build_data_media(profile: ScalarI3Profile) -> list[dict[str, Any]]: data_count = _data_tape_count(profile) per_slot_gen = _tape_generation_boundaries(profile, data_count) @@ -252,7 +233,7 @@ def _build_data_media(profile: ScalarI3Profile) -> list[dict[str, Any]]: # Keep existing barcode pattern to remain compatible with seeded demo data/tests. "barcode": f"VOL{slot:03d}L9", "type": tape_type, - "partition": _partition_name_for_slot(slot, profile), + "partition": "partition1", "slotAddress": _slot_address(slot, profile.slot_count), "mockSlotId": slot, "role": "data", @@ -345,27 +326,6 @@ def _build_partition(profile: ScalarI3Profile, data_media: list[dict[str, Any]], } -def _build_partitions(profile: ScalarI3Profile, drives: list[dict[str, Any]]) -> list[dict[str, Any]]: - per_partition_slots = math.ceil(profile.slot_count / profile.partition_count) - partitions: list[dict[str, Any]] = [] - for index in range(profile.partition_count): - start = index * per_partition_slots + 1 - end = min((index + 1) * per_partition_slots, profile.slot_count) - partition_drives = [d["id"] for i, d in enumerate(drives) if i % profile.partition_count == index] - partitions.append( - { - "name": f"partition{index + 1}", - "id": f"PART-{index + 1:03d}", - "status": "online", - "type": "data", - "slotCount": max(0, end - start + 1), - "ieSlotCount": profile.ie_slot_count, - "driveIds": partition_drives, - } - ) - return partitions - - def _build_ie_stations(profile: ScalarI3Profile) -> list[dict[str, Any]]: return [ { @@ -439,10 +399,6 @@ def build_scalar_i3_config(profile: ScalarI3Profile) -> dict[str, Any]: "ieStations": _build_ie_stations(profile), "ltfsSections": _build_ltfs_sections(profile, data_media, drives), } - # The `partitions` list is additive and only present for multi-partition - # profiles, so single-partition (incl. default) output stays byte-identical. - if profile.partition_count > 1: - config["partitions"] = _build_partitions(profile, drives) return config @@ -457,6 +413,13 @@ def scalar_i3_default_config() -> dict[str, Any]: _PROFILE_NAME_RE = re.compile(r"\Ascalar-i3-(\d+)-(\d+)(?:-.*)?\Z") +def _parse_int_env(name: str, value: str) -> int: + try: + return int(value) + except ValueError as exc: + raise ValueError(f"{name} must be an integer; got {value!r}") from exc + + def _profile_from_env() -> ScalarI3Profile | None: """Build a profile from the contracted EMULATOR_* env knobs, or None if unset.""" profile_name = os.environ.get("EMULATOR_PROFILE", "").strip() @@ -466,12 +429,19 @@ def _profile_from_env() -> ScalarI3Profile | None: if not any((profile_name, slot_override, drive_override, occupancy_override)): return None + # A named profile with no per-field overrides is returned verbatim so its canonical + # drive list (drive_overrides) is preserved — selecting scalar-i3-50-3 must reproduce + # the byte-identical default, drive load counts and all. + if profile_name in NAMED_PROFILES and not any( + (slot_override, drive_override, occupancy_override) + ): + return NAMED_PROFILES[profile_name] + base = DEFAULT_PROFILE slot_count = base.slot_count drive_count = base.drive_count occupancy = base.occupancy_percent ie_slot_count = base.ie_slot_count - partition_count = base.partition_count mix: tuple[str, ...] | None = None if profile_name: @@ -482,7 +452,6 @@ def _profile_from_env() -> ScalarI3Profile | None: drive_count = named.drive_count occupancy = named.occupancy_percent ie_slot_count = named.ie_slot_count - partition_count = named.partition_count mix = named.drive_generation_mix else: match = _PROFILE_NAME_RE.match(profile_name) @@ -496,11 +465,11 @@ def _profile_from_env() -> ScalarI3Profile | None: # Per-field env overrides win over the parsed/named profile. if slot_override: - slot_count = int(slot_override) + slot_count = _parse_int_env("EMULATOR_SLOT_COUNT", slot_override) if drive_override: - drive_count = int(drive_override) + drive_count = _parse_int_env("EMULATOR_DRIVE_COUNT", drive_override) if occupancy_override: - occupancy = int(occupancy_override) + occupancy = _parse_int_env("EMULATOR_OCCUPANCY_PERCENT", occupancy_override) # Derive the generation mix when a raw count changed it (extend LTO-7…LTO-8). if mix is None or len(mix) != drive_count: @@ -512,7 +481,6 @@ def _profile_from_env() -> ScalarI3Profile | None: drive_generation_mix=mix, occupancy_percent=occupancy, ie_slot_count=ie_slot_count, - partition_count=min(partition_count, drive_count), drive_overrides=None, ) diff --git a/tests/i3/test_18_config_profiles.py b/tests/i3/test_18_config_profiles.py index 103b436..6b4988c 100644 --- a/tests/i3/test_18_config_profiles.py +++ b/tests/i3/test_18_config_profiles.py @@ -84,35 +84,25 @@ def test_named_profile_is_internally_consistent(name: str) -> None: # 6. Data media generations are all readable by some drive in the library. drive_generations = {str(d["type"]) for d in config["drives"]} assert {str(m["type"]) for m in data} <= drive_generations - - # 7. Partition bookkeeping. - if profile.partition_count > 1: - partitions = config["partitions"] - assert len(partitions) == profile.partition_count - assert sum(p["slotCount"] for p in partitions) == profile.slot_count - else: - assert "partitions" not in config assert config["partition"]["ieSlotCount"] == profile.ie_slot_count @pytest.mark.parametrize( - "mix,slot_count,occupancy,partitions", + "mix,slot_count,occupancy", [ - (("LTO-8",) * 7, 50, 60, 1), # > 6 drives (exceeds i3 max) - ((), 50, 60, 1), # 0 drives - (("LTO-7", "LTO-8"), 50, 101, 1), # occupancy out of range - (("LTO-7", "LTO-8"), 2, 60, 1), # slot_count < drive_count + 1 - (("LTO-6", "LTO-8"), 50, 60, 1), # unknown generation - (("LTO-7", "LTO-8"), 50, 60, 9), # partitions > drives + (("LTO-8",) * 7, 50, 60), # > 6 drives (exceeds i3 max) + ((), 50, 60), # 0 drives + (("LTO-7", "LTO-8"), 50, 101), # occupancy out of range + (("LTO-7", "LTO-8"), 2, 60), # slot_count < drive_count + 1 + (("LTO-6", "LTO-8"), 50, 60), # unknown generation ], ) -def test_invalid_profile_fails_fast(mix, slot_count, occupancy, partitions) -> None: +def test_invalid_profile_fails_fast(mix, slot_count, occupancy) -> None: profile = ScalarI3Profile( profile_name="invalid", slot_count=slot_count, drive_generation_mix=mix, occupancy_percent=occupancy, - partition_count=partitions, ) with pytest.raises(ValueError): build_scalar_i3_config(profile) @@ -141,8 +131,10 @@ def _restore_default_context() -> Iterator[None]: def test_running_app_reflects_selected_profile( profile_name: str, tmp_path, - monkeypatch: pytest.MonkeyPatch, + # _restore_default_context is requested before monkeypatch so its finalizer runs + # AFTER monkeypatch reverts EMULATOR_PROFILE — the rebuilt default context is clean. _restore_default_context: None, + monkeypatch: pytest.MonkeyPatch, ) -> None: for name in _EMU_ENV: monkeypatch.delenv(name, raising=False) diff --git a/tests/unit/test_i3_config_builder.py b/tests/unit/test_i3_config_builder.py index 1d4808f..ff51bbc 100644 --- a/tests/unit/test_i3_config_builder.py +++ b/tests/unit/test_i3_config_builder.py @@ -41,7 +41,7 @@ def test_default_profile_shape() -> None: assert len(data) == 28 assert len(cleaning) == 3 assert config["partition"]["ieSlotCount"] == 2 - assert "partitions" not in config # single-partition profiles stay additive-key-free + assert "partitions" not in config # every shipped profile is single-partition def test_default_rebuild_is_deterministic_and_independent() -> None: @@ -58,6 +58,19 @@ def test_active_config_equals_default_without_env() -> None: assert scalar_i3_active_config() == scalar_i3_default_config() +def test_named_default_profile_reproduces_default(monkeypatch: pytest.MonkeyPatch) -> None: + # Selecting the default profile by name must reproduce the canonical default + # exactly — including the drive list (load counts etc.), not a regenerated one. + monkeypatch.setenv("EMULATOR_PROFILE", "scalar-i3-50-3") + assert scalar_i3_active_config() == scalar_i3_default_config() + + +def test_non_integer_env_knob_names_the_variable(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("EMULATOR_SLOT_COUNT", "fifty") + with pytest.raises(ValueError, match="EMULATOR_SLOT_COUNT"): + scalar_i3_active_config() + + def test_data_barcodes_stay_default_bound(monkeypatch: pytest.MonkeyPatch) -> None: # Demo NAS seeding depends on these; they must not follow the active profile. monkeypatch.setenv("EMULATOR_PROFILE", "scalar-i3-25-1") @@ -99,7 +112,6 @@ def test_env_parsed_profile_name(monkeypatch: pytest.MonkeyPatch) -> None: {"occupancy_percent": 101}, {"slot_count": 2}, # < drive_count + 1 {"drive_generation_mix": ("LTO-6", "LTO-7", "LTO-8")}, # unknown gen - {"partition_count": 9}, # > drive_count ], ) def test_invalid_config_fails_fast(kwargs: dict) -> None: