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
21 changes: 17 additions & 4 deletions openblade/api/routes_aml_move_medium.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
is a physical ``frame/rack/section/column/row`` + type keyed on a globally-unique
SCSI element address. Modelling that faithfully is roadmap Phase 0
(``ScalarCoordinate``).
- ``moveClass`` is handled as small integers (0 = normal, 3 = unload-to-home). The
real contract is a bit field with different values (e.g. unload) — UNVERIFIED
against a capture.
- ``moveClass`` is parsed as the documented bit field (0=Normal, 8=Unload,
16=No-Eject, 32=Closest-slot; the single-integer 3=Unload is deprecated but still
accepted) via ``MoveClass.from_wire`` — see docs/reference/i3-contract-notes.md.

Because the emulator and the ``scalar_http`` client currently share these
simplifications, client<->emulator tests passing does NOT prove i3 fidelity. That
Expand Down Expand Up @@ -79,6 +79,13 @@ async def move_medium(
except (TypeError, ValueError):
move_class = MoveClass.NORMAL
Comment on lines 79 to 80

if not move_class.is_supported_on_i3:
# Import(2)/Export(4) are documented as not supported on Scalar i3/i6.
raise HTTPException(
status_code=422,
detail="moveClass import/export is not supported on Scalar i3/i6",
)

source = _parse_coordinate(data.get("sourceCoordinate"))
if source is None:
raise HTTPException(
Expand All @@ -96,7 +103,13 @@ async def move_medium(
)
# Real moveClass=8 unload takes the drive source only; if a destination
# slot is supplied (client hint), honor it, else the library picks an
# empty/home slot (Web Services manual).
# empty/home slot (Web Services manual). Guard the destination type:
# slot and drive addresses overlap, so an accidental drive destination
# would otherwise silently misroute the cartridge.
if destination is not None and destination[0] != "slot":
Comment on lines 105 to +109
raise HTTPException(
status_code=422, detail="Unload destination must be a storage slot"
)
target_slot = destination[1] if destination is not None else _first_empty_slot(context)
result = context.library.unload(source_address, target_slot)
elif destination is None:
Expand Down
22 changes: 16 additions & 6 deletions openblade/domain/scalar_coordinate.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@
magic integer. These models formalize the real structure so the convenient integer
forms live only in the adapter boundary.

IMPORTANT — values vs structure: the STRUCTURE here (coordinate fields, moveClass
bit flags) mirrors the documented i3 shape. The exact wire VALUES for ``moveClass``
are UNVERIFIED against a real appliance — the external review indicates the real
"unload" value differs from the emulator's current ``3``. The wire mapping is
centralized in :func:`MoveClass.from_wire`/:meth:`MoveClass.to_wire` so a captured
compatibility case can certify/replace it without touching call sites.
Values are from the Quantum Web Services manual (6-68185-01 Rev D); the moveClass
bit values (8=Unload, 16=No-Eject, 32=Closest-slot; single-integer 3=Unload
deprecated) and the coordinate shape are documented, not guessed — see
docs/reference/i3-contract-notes.md. Parsing/serialization is centralized in
:func:`MoveClass.from_wire`/:meth:`MoveClass.to_wire`. The doc is Rev D (2019) and
the target firmware is 341G, so appliance-level certification of the exact numbers
still awaits a capture; the compatibility corpus is wired to enforce it.
"""

from __future__ import annotations
Expand Down Expand Up @@ -88,6 +89,15 @@ class MoveClass(IntFlag):
def is_unload(self) -> bool:
return bool(self & MoveClass.UNLOAD)

@property
def is_supported_on_i3(self) -> bool:
"""Whether this move is supported on a Scalar i3/i6.

The Web Services manual marks Import(2) and Export(4) as "Currently not
supported" on i3/i6; Normal/Unload/No-Eject/Closest-slot are supported.
"""
return not bool(self & (MoveClass.IMPORT | MoveClass.EXPORT))

@classmethod
def from_wire(cls, value: int) -> MoveClass:
"""Parse a moveClass integer into the structured flag.
Expand Down
53 changes: 53 additions & 0 deletions tests/i3/test_scalar_http_move_medium.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,56 @@ def test_load_into_nonexistent_drive_fails_gracefully(backend: ScalarHttpLibrary
result = backend.load(slot_id, 9999)

assert result.success is False # returned, not raised


def test_unload_returns_cartridge_to_the_specified_slot(
backend: ScalarHttpLibraryBackend,
) -> None:
# Regression for the moveClass=8 unload path: the cartridge must land in the
# SPECIFIC target slot the client asked for, not merely "some" slot.
slot_id = _first_occupied_slot(backend)
drive_id = _first_empty_drive(backend)
barcode = backend.get_slot(slot_id).barcode
assert barcode is not None
assert backend.load(slot_id, drive_id).success # source slot now empty

result = backend.unload(drive_id, slot_id)

assert result.success, result.message
landed = backend.get_slot(slot_id).barcode
assert landed is not None and landed.value == barcode.value
assert backend.find_drive_by_barcode(barcode.value) is None # drive is empty


def test_unload_rejects_a_drive_destination(backend: ScalarHttpLibraryBackend) -> None:
# moveClass=8 with a DRIVE destination must be rejected (slot/drive addresses
# overlap, so honoring it would silently misroute the cartridge).
slot_id = _first_occupied_slot(backend)
drive_id = _first_empty_drive(backend)
assert backend.load(slot_id, drive_id).success

body = {
"moveMedium": {
"sourceCoordinate": {"elementType": "drive", "elementAddress": drive_id},
"destinationCoordinate": {"elementType": "drive", "elementAddress": drive_id},
"moveClass": 8,
}
}
resp = backend._session.request("POST", "/aml/media/operations/moveMedium", json=body)
assert resp.status_code == 422


def test_moveMedium_rejects_import_export_on_i3(backend: ScalarHttpLibraryBackend) -> None:
# A real i3/i6 does not support Import(2)/Export(4) moveClass; the emulator
# rejects them rather than silently doing a normal move.
slot_id = _first_occupied_slot(backend)
for move_class in (2, 4):
body = {
"moveMedium": {
"sourceCoordinate": {"elementType": "slot", "elementAddress": slot_id},
"destinationCoordinate": {"elementType": "slot", "elementAddress": slot_id},
"moveClass": move_class,
}
}
resp = backend._session.request("POST", "/aml/media/operations/moveMedium", json=body)
assert resp.status_code == 422, move_class
9 changes: 9 additions & 0 deletions tests/unit/test_scalar_coordinate.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,12 @@ def test_moveclass_accepts_deprecated_unload_and_ignores_unknown_bits() -> None:
# An unknown bit (1) that maps to no flag must NOT be treated as unload.
assert MoveClass.from_wire(1) == MoveClass.NORMAL
assert not MoveClass.from_wire(1).is_unload


def test_moveclass_i3_support_boundary() -> None:
# Import(2)/Export(4) are documented as not supported on i3/i6.
assert not MoveClass.from_wire(2).is_supported_on_i3 # import
assert not MoveClass.from_wire(4).is_supported_on_i3 # export
# Normal / Unload / No-Eject / Closest-slot are supported.
for value in (0, 8, 16, 24, 32, 40):
assert MoveClass.from_wire(value).is_supported_on_i3, value
Loading