Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/emulator-change-gates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions .github/workflows/i3-emulator-compliance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file modified openblade/__pycache__/__init__.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/__pycache__/config.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/api/__pycache__/__init__.cpython-310.pyc
Binary file not shown.
24 changes: 12 additions & 12 deletions openblade/api/aml_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"])
Expand All @@ -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"]
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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"]),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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: []}
Expand Down Expand Up @@ -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"]),
Expand All @@ -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: []}
Expand Down Expand Up @@ -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"]
}


Expand Down Expand Up @@ -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)

Expand Down
12 changes: 10 additions & 2 deletions openblade/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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"]})
Expand Down
Binary file modified openblade/catalog/__pycache__/__init__.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/catalog/__pycache__/db.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/catalog/__pycache__/models.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/catalog/__pycache__/repository.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/domain/__pycache__/__init__.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/domain/__pycache__/backends.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/domain/__pycache__/errors.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/domain/__pycache__/models.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/domain/__pycache__/policies.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/domain/__pycache__/states.cpython-310.pyc
Binary file not shown.
17 changes: 17 additions & 0 deletions openblade/emulator_contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<slots>-<drives>` 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`)
Expand Down Expand Up @@ -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-<profile>` 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

Expand Down
Binary file modified openblade/hardware/__pycache__/__init__.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/hardware/__pycache__/discovery.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/hardware/__pycache__/ltfs.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/hardware/__pycache__/mtx.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/hardware/__pycache__/runner.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/hardware/__pycache__/safety.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/jobs/__pycache__/__init__.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/jobs/__pycache__/format.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/jobs/__pycache__/inventory.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/jobs/__pycache__/queue.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/jobs/__pycache__/restore.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/jobs/__pycache__/verify.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/jobs/__pycache__/worker.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/simulator/__pycache__/__init__.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/simulator/__pycache__/changer.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/simulator/__pycache__/drive.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/simulator/__pycache__/faults.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/simulator/__pycache__/library.cpython-310.pyc
Binary file not shown.
Binary file modified openblade/simulator/__pycache__/ltfs_volume.cpython-310.pyc
Binary file not shown.
Loading
Loading