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..1625bf1 100644 --- a/.github/workflows/i3-emulator-compliance.yml +++ b/.github/workflows/i3-emulator-compliance.yml @@ -105,6 +105,75 @@ 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 + 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/__pycache__/__init__.cpython-310.pyc b/openblade/__pycache__/__init__.cpython-310.pyc index 49e4330..d8cc7e1 100644 Binary files a/openblade/__pycache__/__init__.cpython-310.pyc and b/openblade/__pycache__/__init__.cpython-310.pyc differ diff --git a/openblade/__pycache__/config.cpython-310.pyc b/openblade/__pycache__/config.cpython-310.pyc index 608ed7d..df52a00 100644 Binary files a/openblade/__pycache__/config.cpython-310.pyc and b/openblade/__pycache__/config.cpython-310.pyc differ diff --git a/openblade/api/__pycache__/__init__.cpython-310.pyc b/openblade/api/__pycache__/__init__.cpython-310.pyc index 69283c9..0722d4f 100644 Binary files a/openblade/api/__pycache__/__init__.cpython-310.pyc and b/openblade/api/__pycache__/__init__.cpython-310.pyc differ 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/bootstrap.py b/openblade/bootstrap.py index 137648d..861ab95 100644 --- a/openblade/bootstrap.py +++ b/openblade/bootstrap.py @@ -27,7 +27,7 @@ from openblade.jobs.worker import Worker from openblade.nas.service import NasService from openblade.nas.types import NasDataset, NasFileRecord, NasFileState, NasPool, StoragePolicy -from openblade.simulator.i3_config import scalar_i3_data_barcodes +from openblade.simulator.i3_config import scalar_i3_active_config from openblade.simulator.scenarios import scalar_i3_default structlog.configure() @@ -207,7 +207,15 @@ def _seed_demo_catalog(catalog: CatalogRepository) -> 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 295a4f8..134a98e 100644 Binary files a/openblade/catalog/__pycache__/__init__.cpython-310.pyc and b/openblade/catalog/__pycache__/__init__.cpython-310.pyc differ diff --git a/openblade/catalog/__pycache__/db.cpython-310.pyc b/openblade/catalog/__pycache__/db.cpython-310.pyc index 4b456d2..c41fb27 100644 Binary files a/openblade/catalog/__pycache__/db.cpython-310.pyc and b/openblade/catalog/__pycache__/db.cpython-310.pyc differ diff --git a/openblade/catalog/__pycache__/models.cpython-310.pyc b/openblade/catalog/__pycache__/models.cpython-310.pyc index 7a415a9..4477615 100644 Binary files a/openblade/catalog/__pycache__/models.cpython-310.pyc and b/openblade/catalog/__pycache__/models.cpython-310.pyc differ diff --git a/openblade/catalog/__pycache__/repository.cpython-310.pyc b/openblade/catalog/__pycache__/repository.cpython-310.pyc index b486639..3cb3ff1 100644 Binary files a/openblade/catalog/__pycache__/repository.cpython-310.pyc and b/openblade/catalog/__pycache__/repository.cpython-310.pyc differ diff --git a/openblade/domain/__pycache__/__init__.cpython-310.pyc b/openblade/domain/__pycache__/__init__.cpython-310.pyc index 7d3022f..566ede1 100644 Binary files a/openblade/domain/__pycache__/__init__.cpython-310.pyc and b/openblade/domain/__pycache__/__init__.cpython-310.pyc differ diff --git a/openblade/domain/__pycache__/backends.cpython-310.pyc b/openblade/domain/__pycache__/backends.cpython-310.pyc index 8a20cae..c25e17e 100644 Binary files a/openblade/domain/__pycache__/backends.cpython-310.pyc and b/openblade/domain/__pycache__/backends.cpython-310.pyc differ diff --git a/openblade/domain/__pycache__/errors.cpython-310.pyc b/openblade/domain/__pycache__/errors.cpython-310.pyc index b1b7fc2..626f6d2 100644 Binary files a/openblade/domain/__pycache__/errors.cpython-310.pyc and b/openblade/domain/__pycache__/errors.cpython-310.pyc differ diff --git a/openblade/domain/__pycache__/models.cpython-310.pyc b/openblade/domain/__pycache__/models.cpython-310.pyc index 7aabb78..b72c9bf 100644 Binary files a/openblade/domain/__pycache__/models.cpython-310.pyc and b/openblade/domain/__pycache__/models.cpython-310.pyc differ diff --git a/openblade/domain/__pycache__/policies.cpython-310.pyc b/openblade/domain/__pycache__/policies.cpython-310.pyc index 8036e46..45dfd45 100644 Binary files a/openblade/domain/__pycache__/policies.cpython-310.pyc and b/openblade/domain/__pycache__/policies.cpython-310.pyc differ diff --git a/openblade/domain/__pycache__/states.cpython-310.pyc b/openblade/domain/__pycache__/states.cpython-310.pyc index d2156a7..f37d7e3 100644 Binary files a/openblade/domain/__pycache__/states.cpython-310.pyc and b/openblade/domain/__pycache__/states.cpython-310.pyc differ diff --git a/openblade/emulator_contract/README.md b/openblade/emulator_contract/README.md index dc74082..4da6e3a 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,12 @@ 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` — + 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 93e24ec..0573335 100644 Binary files a/openblade/hardware/__pycache__/__init__.cpython-310.pyc and b/openblade/hardware/__pycache__/__init__.cpython-310.pyc differ diff --git a/openblade/hardware/__pycache__/discovery.cpython-310.pyc b/openblade/hardware/__pycache__/discovery.cpython-310.pyc index 1ecc623..50b2d86 100644 Binary files a/openblade/hardware/__pycache__/discovery.cpython-310.pyc and b/openblade/hardware/__pycache__/discovery.cpython-310.pyc differ diff --git a/openblade/hardware/__pycache__/ltfs.cpython-310.pyc b/openblade/hardware/__pycache__/ltfs.cpython-310.pyc index e66a27a..bcd3537 100644 Binary files a/openblade/hardware/__pycache__/ltfs.cpython-310.pyc and b/openblade/hardware/__pycache__/ltfs.cpython-310.pyc differ diff --git a/openblade/hardware/__pycache__/mtx.cpython-310.pyc b/openblade/hardware/__pycache__/mtx.cpython-310.pyc index 55fe4d3..038978b 100644 Binary files a/openblade/hardware/__pycache__/mtx.cpython-310.pyc and b/openblade/hardware/__pycache__/mtx.cpython-310.pyc differ diff --git a/openblade/hardware/__pycache__/runner.cpython-310.pyc b/openblade/hardware/__pycache__/runner.cpython-310.pyc index 6dce8e9..2ee974c 100644 Binary files a/openblade/hardware/__pycache__/runner.cpython-310.pyc and b/openblade/hardware/__pycache__/runner.cpython-310.pyc differ diff --git a/openblade/hardware/__pycache__/safety.cpython-310.pyc b/openblade/hardware/__pycache__/safety.cpython-310.pyc index 44590a8..e56ae30 100644 Binary files a/openblade/hardware/__pycache__/safety.cpython-310.pyc and b/openblade/hardware/__pycache__/safety.cpython-310.pyc differ diff --git a/openblade/jobs/__pycache__/__init__.cpython-310.pyc b/openblade/jobs/__pycache__/__init__.cpython-310.pyc index ea21fa1..196b356 100644 Binary files a/openblade/jobs/__pycache__/__init__.cpython-310.pyc and b/openblade/jobs/__pycache__/__init__.cpython-310.pyc differ diff --git a/openblade/jobs/__pycache__/format.cpython-310.pyc b/openblade/jobs/__pycache__/format.cpython-310.pyc index ac77fb1..92d947d 100644 Binary files a/openblade/jobs/__pycache__/format.cpython-310.pyc and b/openblade/jobs/__pycache__/format.cpython-310.pyc differ diff --git a/openblade/jobs/__pycache__/inventory.cpython-310.pyc b/openblade/jobs/__pycache__/inventory.cpython-310.pyc index e55175d..7e5e7a7 100644 Binary files a/openblade/jobs/__pycache__/inventory.cpython-310.pyc and b/openblade/jobs/__pycache__/inventory.cpython-310.pyc differ diff --git a/openblade/jobs/__pycache__/queue.cpython-310.pyc b/openblade/jobs/__pycache__/queue.cpython-310.pyc index e5c2c06..b644085 100644 Binary files a/openblade/jobs/__pycache__/queue.cpython-310.pyc and b/openblade/jobs/__pycache__/queue.cpython-310.pyc differ diff --git a/openblade/jobs/__pycache__/restore.cpython-310.pyc b/openblade/jobs/__pycache__/restore.cpython-310.pyc index a29cce4..3c977af 100644 Binary files a/openblade/jobs/__pycache__/restore.cpython-310.pyc and b/openblade/jobs/__pycache__/restore.cpython-310.pyc differ diff --git a/openblade/jobs/__pycache__/verify.cpython-310.pyc b/openblade/jobs/__pycache__/verify.cpython-310.pyc index a3c127e..fcc79aa 100644 Binary files a/openblade/jobs/__pycache__/verify.cpython-310.pyc and b/openblade/jobs/__pycache__/verify.cpython-310.pyc differ diff --git a/openblade/jobs/__pycache__/worker.cpython-310.pyc b/openblade/jobs/__pycache__/worker.cpython-310.pyc index 2a5c683..d458e78 100644 Binary files a/openblade/jobs/__pycache__/worker.cpython-310.pyc and b/openblade/jobs/__pycache__/worker.cpython-310.pyc differ diff --git a/openblade/simulator/__pycache__/__init__.cpython-310.pyc b/openblade/simulator/__pycache__/__init__.cpython-310.pyc index 65d7a7b..b18d9a3 100644 Binary files a/openblade/simulator/__pycache__/__init__.cpython-310.pyc and b/openblade/simulator/__pycache__/__init__.cpython-310.pyc differ diff --git a/openblade/simulator/__pycache__/changer.cpython-310.pyc b/openblade/simulator/__pycache__/changer.cpython-310.pyc index c472ef6..bb48654 100644 Binary files a/openblade/simulator/__pycache__/changer.cpython-310.pyc and b/openblade/simulator/__pycache__/changer.cpython-310.pyc differ diff --git a/openblade/simulator/__pycache__/drive.cpython-310.pyc b/openblade/simulator/__pycache__/drive.cpython-310.pyc index eb5e488..2a44325 100644 Binary files a/openblade/simulator/__pycache__/drive.cpython-310.pyc and b/openblade/simulator/__pycache__/drive.cpython-310.pyc differ diff --git a/openblade/simulator/__pycache__/faults.cpython-310.pyc b/openblade/simulator/__pycache__/faults.cpython-310.pyc index d4b3521..dbc94d8 100644 Binary files a/openblade/simulator/__pycache__/faults.cpython-310.pyc and b/openblade/simulator/__pycache__/faults.cpython-310.pyc differ diff --git a/openblade/simulator/__pycache__/library.cpython-310.pyc b/openblade/simulator/__pycache__/library.cpython-310.pyc index c077164..24d063d 100644 Binary files a/openblade/simulator/__pycache__/library.cpython-310.pyc and b/openblade/simulator/__pycache__/library.cpython-310.pyc differ diff --git a/openblade/simulator/__pycache__/ltfs_volume.cpython-310.pyc b/openblade/simulator/__pycache__/ltfs_volume.cpython-310.pyc index 891d898..a0eb08b 100644 Binary files a/openblade/simulator/__pycache__/ltfs_volume.cpython-310.pyc and b/openblade/simulator/__pycache__/ltfs_volume.cpython-310.pyc differ diff --git a/openblade/simulator/i3_config.py b/openblade/simulator/i3_config.py index 6aed3d9..6eb5d14 100644 --- a/openblade/simulator/i3_config.py +++ b/openblade/simulator/i3_config.py @@ -1,39 +1,231 @@ -"""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 + # 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.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})" + ) + + +# --- 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, + 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, + ), + # 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. +} + + +def _slots_per_bay(slot_count: int) -> int: + return slot_count // 2 -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 + +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. -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 + 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 _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( @@ -42,16 +234,16 @@ def _build_data_media() -> list[dict[str, Any]]: "barcode": f"VOL{slot:03d}L9", "type": tape_type, "partition": "partition1", - "slotAddress": _slot_address(slot), + "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 +253,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 +283,51 @@ 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_ie_stations(profile: ScalarI3Profile) -> list[dict[str, Any]]: + return [ { "id": "IE-1", "serialNumber": "IE0001", @@ -168,11 +341,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 +365,147 @@ 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), + } + 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 _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() + 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 + + # 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 + 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 + 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 = _parse_int_env("EMULATOR_SLOT_COUNT", slot_override) + if drive_override: + drive_count = _parse_int_env("EMULATOR_DRIVE_COUNT", drive_override) + if 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: + 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, + 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/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")), 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/i3/test_18_config_profiles.py b/tests/i3/test_18_config_profiles.py new file mode 100644 index 0000000..6b4988c --- /dev/null +++ b/tests/i3/test_18_config_profiles.py @@ -0,0 +1,160 @@ +"""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 + assert config["partition"]["ieSlotCount"] == profile.ie_slot_count + + +@pytest.mark.parametrize( + "mix,slot_count,occupancy", + [ + (("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) -> None: + profile = ScalarI3Profile( + profile_name="invalid", + slot_count=slot_count, + drive_generation_mix=mix, + occupancy_percent=occupancy, + ) + 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, + # _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) + 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 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: diff --git a/tests/unit/test_i3_config_builder.py b/tests/unit/test_i3_config_builder.py new file mode 100644 index 0000000..ff51bbc --- /dev/null +++ b/tests/unit/test_i3_config_builder.py @@ -0,0 +1,136 @@ +"""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 # every shipped profile is single-partition + + +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_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") + 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 + ], +) +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