Compare commits

...

382 Commits

Author SHA1 Message Date
Olivier Floch
2ac7c0ffb8 test(web): seed the V2V review step on top of the list pool seed
seedV2vReviewStepCollections adds the migrated VM's pool and SRs to what seedV2vListPool seeds instead of seeding the list pool and its default SRs again
2026-11-09 18:48:04 +01:00
Olivier Floch
68ef82a9f3 fix(web-core): add the zero form of the French V2V review step counts 2026-11-09 18:48:04 +01:00
Olivier Floch
565dbfa835 refactor(web): read the V2V connection from the wizard context in the review step 2026-11-09 18:48:04 +01:00
Olivier Floch
a8952875ac docs(changelog): add the V2V migration wizard review step 2026-11-09 18:48:03 +01:00
Olivier Floch
7b44a15a51 test(web): share the running job expectation
The 7 job tests checking the "in progress" error of a running job use expectJobRunning; the V2V idle job tests check canRun alone, as the other job tests (canRun is the absence of error).
2026-11-09 18:48:03 +01:00
Olivier Floch
22ed4ac652 test(web): share the V2V check literals
Every V2V selected VM and source VM check a test expects comes from V2V_SELECTED_VM_CHECK, V2V_VM_CHECK or createV2vUnsupportedCheck, built from literals so a broken getV2vUnsupportedCheck fails its tests.
2026-11-09 18:48:03 +01:00
Olivier Floch
ee90cea718 test(web): share the V2V wizard component mount 2026-11-09 18:48:03 +01:00
Olivier Floch
1f2469b711 refactor(web-core): share the input wrapper message merge 2026-11-09 18:47:59 +01:00
Olivier Floch
533f69244b test(web): cover the remaining V2V review step branches 2026-11-09 18:47:58 +01:00
Olivier Floch
fbcf5ad7f7 refactor(web): share the V2V pool name, import VM, row lookup and list step mount
- useV2vPoolName names a pool through getV2vTargetName
- useV2vImportVm names the pool of an import VM and reads its active mapping, used by useV2vResumedVm and the review step
- useV2vSelectedVms exposes getRow and getName, used by the review step
- mountV2vListStep replaces mountV2vMappingStep and mountV2vReviewStep
2026-11-09 18:47:47 +01:00
Olivier Floch
104cdfbcb1 feat(web): add the review step of the V2V migration wizard 2026-11-09 18:47:47 +01:00
Olivier Floch
574e427b0a feat(web-core): add VtsSuccessStateHero component 2026-11-09 18:47:47 +01:00
Olivier Floch
48b445211b refactor(lite): use VtsNumberInput in the network forms
The network MTU and VLAN messages now wrap, as in the XO 6 forms.
2026-11-09 18:47:47 +01:00
Olivier Floch
a54e37c808 feat(web): add the V2V launch options cards 2026-11-09 18:47:47 +01:00
Olivier Floch
48ad080ada feat(web): add an info line to FormCheckbox
The VDI attach form moves from its own VdiFormCheckbox to FormCheckbox.
2026-11-09 18:47:46 +01:00
Olivier Floch
210e4f124e refactor(web): use VtsNumberInput in the network and traffic rule forms
The network MTU and VLAN messages now wrap, as in every other XO 6 form input.
2026-11-09 18:47:46 +01:00
Olivier Floch
e7da1be6ba feat(web-core): add VtsNumberInput component 2026-11-09 18:47:46 +01:00
Olivier Floch
d10aa60358 feat(web): add the V2V start job
- useXoV2vStartJob posts the launch to the pool import route and returns
  its task id, without monitoring it: an import lasts hours
- useV2vLaunchForm: stop the source VMs and stop on error by default, two
  VMs at a time, concurrency an integer from 1 to 8 as the back end accepts
- Dev mock: the import route answers a task id, or the problems the back
  end would refuse the launch for
- New test helper: createV2vStartPayloadInput, the input of buildV2vStartPayload
2026-11-09 18:47:46 +01:00
Olivier Floch
374d2c9d39 feat(web): add the V2V launch plan, options and start payload utils
- getV2vLaunchPlan groups the list VMs as the selected VMs panel checks them
  (excluded, already migrated, resumed, new) and keeps the launchable ones,
  an already migrated VM only once confirmed
- getV2vVmStopSource / setV2vVmStopSource: the stop source option of a VM,
  from the launch settings or its override; resolveV2vValue now gives the
  value and origin of any V2V override (resolveV2vTarget builds on it)
- buildV2vStartPayload sends only what the back end accepts (no start, no
  cleanup, no tags, MACs kept), no mapping for a resumed VM;
  getV2vStartProblems reads the problems of a refused launch
- getV2vCutoverAt: the date of the complete import snapshot
- getUnprocessableEntityData shared by the V2V connection and start errors
- New test helpers: createApiError, V2V_SELECTED_VM_CHECK, the V2V launch
  factories and createV2vCompleteImportSnapshot
2026-11-09 18:47:46 +01:00
Olivier Floch
b82d641783 test(web): provide the V2V mapping connection beside the other connection provides
The resumed VM and VM actions tests read it without mounting a mapping. The actions test only needs a
connection to open the drawer under: it gets a plain one.
2026-11-09 18:39:29 +01:00
Olivier Floch
3a41fba148 fix(web): open the V2V VM settings drawer outside the wizard context
- The drawer is an overlay rendered at the app root, out of the migration page: reading the V2V connection there
  threw, so Edit settings opened nothing
- useV2vVmTargets: the targets and options the drawer edits, read from an inventory handed in; useV2vVmMapping adds
  the pool name, host and checks of the side panel on top
- useV2vVmSettingsEdit hands the drawer the inventory of the connected source
- Tests: the drawer is mounted without the wizard connection; v2vMappingConnectionProvide shared by the mapping tests
2026-11-09 18:39:29 +01:00
Olivier Floch
1667ed2a16 test(web): seed every V2V list pool and list VMs through the shared helpers
- seedV2vListPool takes partial offers and the other pool, and sets the collections itself: seedChoices is gone
- the wizard, pool name and source-and-destination tests seed the list pool through it
- createV2vMigrationListVmsOf builds the list VMs of given ids, replacing the hand-written ones; V2vMappingStep reads the stored VM through readV2vStoredListVm
2026-11-09 12:43:34 +01:00
Olivier Floch
55cf10d8bf test(web): seed the pool of the V2V list through one helper
- `seedV2vListPool` seeds the pool of the list and its offers, in place of
  the same two lines in the VMs step, selected VMs and mapping step tests
- the resumed VM is imported to the pool of the list: `V2V_RESUMED_VM_POOL`
  is gone
- the network and template of the other pool get their own ids, apart from
  the list defaults
2026-11-09 12:22:53 +01:00
Olivier Floch
7ec1e941a5 test(web): use the shared list pool in the V2V pool name test 2026-11-09 11:57:47 +01:00
Olivier Floch
f8dc85855b test(web): use the shared list pool in the V2V VM mapping mount 2026-11-09 11:56:46 +01:00
Olivier Floch
8ae1f4e811 test(web): use the shared id of another pool in the V2V VM mapping mount 2026-11-09 11:02:57 +01:00
Olivier Floch
1c1960a09c fix(web-core): add the zero form of the French V2V snapshot check 2026-11-09 11:02:57 +01:00
Olivier Floch
892fff90ab refactor(web): read the V2V connection from the wizard context in the mapping step 2026-11-09 11:02:57 +01:00
Olivier Floch
d4c8b7aef4 test(web): share the useOverlay mock
- mock-overlay.ts records each useOverlay call, in order, as its options and the open spy it returned
- used by the backup repository, plugin drawer job and V2V settings tests, in place of 7 hand-written mocks
2026-11-09 11:02:57 +01:00
Olivier Floch
11b3302b62 test(web): share the V2V mapping test fixtures
- createV2vOfferedTarget: a resolved target among the pool offers
- V2V_LIST_VM_OVERRIDES, V2V_FULLY_OVERRIDDEN_LIST_VM, V2V_OTHER_LIST_VM: the list VMs the mapping tests start from
- V2V_NEW_STEP_VM, V2V_RESUMED_STEP_VM, V2V_NOT_FOUND_STEP_VM: one mapping step VM of each kind
- describeV2vMappingCardLayout, countDividers: the layout the side panel mapping cards share
- mountV2vVmKindActions, findV2vRouteQuery
- the drawer cancel cases, the table menu order and the panel kind switch no longer re-test what another test covers
2026-11-09 11:02:57 +01:00
Olivier Floch
587d273e67 fix(web): type the pool connection success server id as FrontXoServer
XoServer['id'] is branded: Vue resolves it to a runtime Object check and warns on the string id.
2026-11-09 11:02:56 +01:00
Olivier Floch
3dd6126425 fix(web): give the V2V VM heading card id a string prop type
XoV2vVm['id'] is branded: Vue resolves it to a runtime Object check and warns on every panel render.
2026-11-09 11:02:56 +01:00
Olivier Floch
92496e7a19 refactor(web-core): remove the unused selected state of UiPanelCard
The V2V mapping step no longer selects a panel card since it shows its VMs as a table.
2026-11-09 11:02:56 +01:00
Olivier Floch
d6e7324892 feat(web): show the V2V mapping step as a table 2026-11-09 11:02:56 +01:00
Olivier Floch
13986f2828 feat(web): add the V2V VM side panel 2026-11-09 11:02:41 +01:00
Olivier Floch
f7cad58fa3 feat(web): add the V2V VM settings drawer
Edit settings opens a drawer holding the per-VM settings of a new VM on a
draft: SR per disk, network per NIC, keep MAC, template and tags. Save
replaces the stored overrides of the VM once, Cancel writes nothing.
The options card becomes V2vVmOptionsFields, so the drawer reuses them.
2026-11-09 11:02:41 +01:00
Olivier Floch
d1e023bdd4 refactor(web): add an edit draft to the V2V VM mapping
The VM mapping reads its overrides from an injectable source, so that
useV2vVmMappingDraft edits a copy of them and hands back the changed
overrides, without writing the stored list.
2026-11-09 11:02:41 +01:00
Olivier Floch
710cc1915e docs(changelog): add the V2V migration wizard mapping step 2026-11-09 11:02:41 +01:00
Olivier Floch
873644d9e0 fix(web): let the V2V source step cards shrink on a small screen 2026-11-09 11:02:41 +01:00
Olivier Floch
35082b5d9e feat(web): add the mapping step of the V2V migration wizard 2026-11-09 11:02:41 +01:00
Olivier Floch
a8ab6a1f99 feat(web): add the V2V resumed VM mapping 2026-11-09 10:59:59 +01:00
Olivier Floch
fb1da9305e refactor(web): extract the VM delete flow into useVmDelete 2026-11-09 10:59:55 +01:00
Olivier Floch
04d86aca15 test(web): share the useXoRoutes mock 2026-11-09 10:59:49 +01:00
Olivier Floch
73207050a0 feat(web): add the V2V new VM mapping 2026-11-09 10:59:49 +01:00
Olivier Floch
a61182a466 feat(web): store the per-VM overrides of the V2V migration list 2026-11-09 10:59:36 +01:00
Olivier Floch
6866393ba8 feat(web): add V2V mapping, checks and active mapping utils
- per-VM overrides in the migration list (disks, NICs, template, keep MAC,
  tags), parsed with their shape
- one mapping resolver: each target from its override or the list default,
  offered or not, and the mapping complete or incomplete
- `mapping-incomplete` check of a new VM, after `partial-on-other-pool`
- mapping summary from the resolved mapping, with distinct target names
- source VM checks (unsupported disks, snapshots), next transfer (delta or
  full), datacenter of the VM
- active mapping of a resumed VM: disk to VDI by its `esxi_diskPath` in the
  disk chain, NIC to VIF by device, MAC kept or not
2026-11-09 10:59:16 +01:00
Olivier Floch
d1358444e7 feat(web-core): add a selected state to UiPanelCard 2026-11-09 10:57:55 +01:00
Olivier Floch
75e990343a feat(web-core): add VtsTagsInput component 2026-11-09 10:57:55 +01:00
Olivier Floch
8d25cb6b94 feat(types): add the vmdk chain paths of a V2V source disk 2026-11-09 10:57:55 +01:00
Olivier Floch
c099fabb2b test(web): read the first two V2V list VMs from the stored list fixture 2026-11-09 10:51:50 +01:00
Olivier Floch
b20630c512 test(web): read the V2V listed VM, empty and stored lists from the shared fixtures 2026-11-09 10:27:15 +01:00
Olivier Floch
e63e6a3810 test(web): read the V2V other pool and empty list from the shared fixtures 2026-11-09 10:15:03 +01:00
Olivier Floch
8d69b4095e test(web): read the V2V list pool and pair from the shared fixtures 2026-11-09 09:57:02 +01:00
Olivier Floch
43eefcf01f fix(web): drop the unstyled root class of the inline parts 2026-11-09 09:57:02 +01:00
Olivier Floch
67ea6b5a53 test(web): use the shared id of another pool in the V2V VMs step tests 2026-11-09 09:57:02 +01:00
Olivier Floch
c1c2673441 refactor(web): name the V2V selected VMs test pool id after the id it holds 2026-11-09 09:57:02 +01:00
Olivier Floch
23ffab2373 refactor(web): name the V2V list summary after its source and pool pair, not a lock 2026-11-09 09:57:02 +01:00
Olivier Floch
2a4c19d281 fix(web-core): drop the V2V empty list translation no screen uses any more 2026-11-09 09:57:02 +01:00
Olivier Floch
ba7059af89 docs(web): drop the removed mountComposable render option from the testing guide 2026-11-09 09:57:02 +01:00
Olivier Floch
a51c206614 fix(web): say that another V2V source or pool opens its own list
The selected VMs panel claimed the source and destination were locked
until the list was cleared, while each (source, pool) pair has its own
list and choosing another one just opens it.
2026-11-09 09:57:02 +01:00
Olivier Floch
3532de374d test(web): keep the fetch stub of the test setup under its comment 2026-11-09 09:57:02 +01:00
Olivier Floch
f27fdb5c96 style(web): drop the V2V comments that only say what the code does 2026-11-09 09:57:02 +01:00
Olivier Floch
79d303db55 fix(web): open and close the V2V selected VMs panel like the other side panels
Its VMs act as the selection of the side panel: it opens with them and
closes when the list is emptied, without forcing it closed on small screens.
2026-11-09 09:57:02 +01:00
Olivier Floch
e70963dc74 fix(web-core): show the persistent side panel close button wherever closing collapses it
On a large screen with the panel unpinned, a persistent panel could not be
closed: closing collapses it there as on small screens.
2026-11-09 09:57:02 +01:00
Olivier Floch
a654570dd1 test(web): share the V2V datastore filters and the VMs step query 2026-11-09 09:57:02 +01:00
Olivier Floch
38522688ca fix(web): name the root of the V2V selected VMs panel and item after the component 2026-11-09 09:57:02 +01:00
Olivier Floch
b5a6a0f2b2 fix(web-core): add the zero form of the French V2V VM step counts 2026-11-09 09:57:02 +01:00
Olivier Floch
9f10b3f942 refactor(web): read the V2V connection from the wizard context in the VMs step 2026-11-09 09:57:02 +01:00
Olivier Floch
8c14ebbb0c fix(web): leave a V2V VM not found in the source without a kind tag
A VM gone from the source is neither new nor resumed: its check alone tells it.
2026-11-09 09:57:02 +01:00
Olivier Floch
6838e0b536 test(web): read the plugin header tags with the shared findAccentedTags 2026-11-09 09:57:02 +01:00
Olivier Floch
4d671f71cd test(web): clear the local storage before every test
happy-dom keeps the local storage across the tests of a file: the global
test setup now clears it, instead of each V2V test file doing it again.
2026-11-09 09:57:02 +01:00
Olivier Floch
dc43545389 feat(web): add the VMs step of the V2V migration wizard 2026-11-09 09:57:02 +01:00
Olivier Floch
30a21d98a4 feat(web-core): add a persistent side panel, closable on small screens only
A panel whose content is not a selection has nothing to clear: its close
button only shows on small screens, where it leaves the full-screen panel.
2026-11-09 09:57:02 +01:00
Olivier Floch
bacd60115c feat(web): add the V2V selected VMs panel 2026-11-09 09:57:02 +01:00
Olivier Floch
a27f0cb5ed feat(web): add the V2V VM selection table 2026-11-09 09:57:02 +01:00
Olivier Floch
60272ed6cf feat(web): add the V2V VM query builder, presets and datastore filter 2026-11-09 09:57:02 +01:00
Olivier Floch
745b6a7012 feat(web): add, remove and clear VMs of the V2V migration list 2026-11-09 09:56:56 +01:00
Olivier Floch
8583fcdd32 feat(web): add V2V VM status, checks and rows utils 2026-11-09 09:56:56 +01:00
Olivier Floch
2a4e45f4dc feat(web-core): add a disabled state to the table checkbox column 2026-11-09 09:19:28 +01:00
Olivier Floch
0d7fba2b6a refactor(web): require the VMs of a built V2V migration list 2026-11-09 09:14:10 +01:00
Olivier Floch
9efc4df5e6 refactor(web): share the writable SR of a pool check between the V2V destination, VDI and VM migrate forms 2026-11-09 09:13:22 +01:00
Olivier Floch
cdda6f6e2a test(web): share the id of another pool between the tests 2026-11-09 09:12:36 +01:00
Olivier Floch
d85415f28e test(web): cover the V2V pool option slot of the destination pool card and name the id-only test params after the id 2026-11-09 08:56:27 +01:00
Olivier Floch
890a8aedae test(web): share the V2V pool option mount and the stored list with VMs of the destination step test 2026-11-09 08:44:27 +01:00
Olivier Floch
422512514a test(web): share the V2V destination pool seeding and cover the default SR of another pool 2026-11-06 17:27:01 +01:00
Olivier Floch
ee217dbf45 fix(web-core): describe the V2V destination pool card as one list per source and pool 2026-11-06 17:09:42 +01:00
Olivier Floch
f6f5f05696 refactor(web): name the V2V destination pool id variables after the id they hold 2026-11-06 17:08:53 +01:00
Olivier Floch
1acd6533fc refactor(web): read the V2V destination pool with useGetPoolById 2026-11-06 16:58:56 +01:00
Olivier Floch
12cf925a89 test(web): share the collections mock, the select selection reader and the V2V destination choice helpers 2026-11-06 16:58:03 +01:00
Olivier Floch
d550e4356b test(web): cover the host and unknown locations of getSrLocation 2026-11-06 16:41:53 +01:00
Olivier Floch
03fc6b5b1a refactor(web-core): share the • separator of inline parts as the interpunct-list class 2026-11-06 16:17:25 +01:00
Olivier Floch
5d79b4486e test(web): mount the FormSelect test with mountWithSelect 2026-11-06 16:07:49 +01:00
Olivier Floch
2ea5887ad1 test(web): share the V2V destination form select mount and test getSrSelectLabel where it lives 2026-11-06 16:06:58 +01:00
Olivier Floch
9fc2dc2b7c refactor(web): count the V2V destination pool resources with lodash countBy 2026-11-06 16:02:05 +01:00
Olivier Floch
c89eab12bf refactor(web): reuse getSrSelectLabel for the V2V destination SR options 2026-11-06 15:15:17 +01:00
Olivier Floch
d63f6a794c fix(web-core): sort the V2V destination translation keys and drop an unused one
Sort destination-pool alphabetically and drop the unused source-backup-repository key.
2026-11-06 15:15:17 +01:00
Olivier Floch
2739715642 refactor(web): read the V2V connection from the wizard context in the destination step 2026-11-06 15:15:17 +01:00
Olivier Floch
26eadbd6a4 fix(web): prefill the V2V destination defaults once their options are loaded
A select only shows a value it already offers: defaults set before the networks, PIFs or templates of the pool
were loaded (e.g. reopening a stored list after a reload) read empty although the form held them.
2026-11-06 15:15:17 +01:00
Olivier Floch
79fff5af37 feat(web): add the destination step of the V2V migration wizard 2026-11-06 15:15:17 +01:00
Olivier Floch
8be55f783b feat(web): wire the V2V destination step to the route and the stored migration list 2026-11-06 15:15:16 +01:00
Olivier Floch
2655842321 feat(web): add the V2V destination form 2026-11-06 15:15:16 +01:00
Olivier Floch
f593012837 feat(web): keep the V2V migration list of each source and pool in the browser 2026-11-06 15:15:16 +01:00
Olivier Floch
a2f590be31 feat(web): add V2V migration list and destination utils 2026-11-06 15:15:16 +01:00
Olivier Floch
bcecc5ded0 refactor(web): test when the V2V prerequisite lets a source connect 2026-11-06 15:02:05 +01:00
Olivier Floch
7fe262fbbe refactor(web): replace the V2V source kind with a vCenter check 2026-11-06 15:00:19 +01:00
Olivier Floch
dafc829cee test(web): share the V2V invalid credentials response stub 2026-11-06 14:58:22 +01:00
Olivier Floch
8ea46eb530 test(web): seed the remembered V2V connections when mounting the source step 2026-11-06 14:56:38 +01:00
Olivier Floch
9e59db67ee test(web): reset the V2V source step stubs between tests 2026-11-06 14:54:48 +01:00
Olivier Floch
73694a0da6 test(web): share the cold and warm V2V import fixture 2026-11-06 14:52:45 +01:00
Olivier Floch
6c201f2117 docs(web): list the V2V connection reader test helper 2026-11-06 12:33:34 +01:00
Olivier Floch
3be9f3e94a test(web): check the V2V remembered connections card slot without a test-only class 2026-11-06 12:26:34 +01:00
Olivier Floch
b1741f44f5 test(web): test that the V2V migration page provides the wizard connection 2026-11-06 12:14:47 +01:00
Olivier Floch
ffd581def2 test(web): test forgetting the V2V password with and without a stored one in one case 2026-11-06 12:13:58 +01:00
Olivier Floch
12cc94e98b docs(web): never add a class only for a test to find an element 2026-11-06 12:11:49 +01:00
Olivier Floch
d80a5f52f0 chore(web): remove the unused class of the V2V remember warning 2026-11-06 12:11:02 +01:00
Olivier Floch
da9044c98e fix(web): show the V2V wizard summary as the head bar status 2026-11-06 12:10:16 +01:00
Olivier Floch
ba01b16d1a fix(web): forget the remembered V2V password when remember is unticked
Connecting with an already remembered connection, edited or not, kept its stored password even with remember unticked.
2026-11-06 12:08:44 +01:00
Olivier Floch
3687817765 docs(changelog): release @xen-orchestra/web with the V2V migration wizard 2026-11-06 12:08:44 +01:00
Olivier Floch
8bd11632f0 test(web): test the new V2V connection per wizard on provideV2vConnection
The page test unmounted by hand and asserted the context lifecycle; it now only checks that the wizard renders.
2026-11-06 12:08:44 +01:00
Olivier Floch
b2fd9180b1 refactor(web): prefix the V2V migration wizard with V2v 2026-11-06 12:08:44 +01:00
Olivier Floch
8c00429922 refactor(web): focus the V2V password from the source step, not its composable 2026-11-06 11:03:01 +01:00
Olivier Floch
3a1191dfe1 chore(web): remove the vCenter or ESXi description of the V2V source form 2026-11-06 10:45:38 +01:00
Olivier Floch
9336c1c8ac chore(web): limit the V2V mock inventory to what the source step reads 2026-11-04 16:49:08 +01:00
Olivier Floch
a3bacc45a3 test(web): silence the expected console errors with the shared helper 2026-11-04 16:48:20 +01:00
Olivier Floch
de2cfc864d test(web): cover the port bounds of the V2V connection form 2026-11-04 16:47:33 +01:00
Olivier Floch
dfa0baf1fa fix(web-core): add the zero form of the French V2V migration counts 2026-11-04 16:47:28 +01:00
Olivier Floch
501bb71dc1 chore(web-core): remove the unused remaining-space translation 2026-11-04 16:47:28 +01:00
Olivier Floch
de1d6ed21d refactor(web): remove the unused toV2vConnection util 2026-11-04 16:46:38 +01:00
Olivier Floch
7d4f325330 refactor(web): share the V2V connection through provide/inject
XO 6 modules hold no Pinia store: the migration page now provides the
connection and its inventory to the wizard, which injects them. They live
as long as the page, so closing the wizard forgets the credentials without
a manual clear.
2026-11-04 16:14:13 +01:00
Olivier Floch
f1b316bd01 chore(web): add a dev-only mock of the V2V REST routes
Enabled with VITE_XO_V2V_MOCK=true until the back end ships the V2V routes.
2026-11-04 16:14:12 +01:00
Olivier Floch
166c4d1e46 feat(web): add the source step of the V2V migration wizard
Administration › V2V › Migrate VMs: connect to a VMware source, new or remembered in the browser, with the vectura prerequisite check and the inventory loading. The other steps are placeholders.
2026-11-04 16:14:12 +01:00
Olivier Floch
a2c83b402f refactor(web): share the connection form inputs and credentials error
Shared FormTextInput, FormPasswordInput, FormCheckbox and the credentials error composable, used by the pool connection and backup repository forms, with their tests and the shared test helpers.
2026-11-04 16:12:47 +01:00
Olivier Floch
d4047e3b9b feat(web-core): add UiNumberedStepper component 2026-11-04 16:12:47 +01:00
Florent BEAUCHAMP
3e6ea4953f test: use rest api in qa-test 2026-10-29 13:23:17 -04:00
Florent BEAUCHAMP
ed9911daf9 cleanup and removing unused code 2026-10-29 13:23:17 -04:00
Florent BEAUCHAMP
3f2d102f81 feat(v2v): populate the other config fields for resume 2026-10-29 13:23:17 -04:00
Florent BEAUCHAMP
c7f16ed2ac refactor: move import action from v2v to pool controller 2026-10-29 13:23:17 -04:00
Florent BEAUCHAMP
a9dba30376 refactor: inventory is a POST not an action 2026-10-29 13:23:17 -04:00
Florent BEAUCHAMP
c9d1514342 cleanup : types, comments and tests 2026-10-29 13:23:17 -04:00
Florent BEAUCHAMP
989bb8591a fix(xo-sevrer): better handling of errors in exportEsxiDisk 2026-10-29 13:23:17 -04:00
Florent BEAUCHAMP
879b5d5991 fix(xo-server): better use of disposable for V2V 2026-10-29 13:23:17 -04:00
Florent BEAUCHAMP
1f9b45a004 fix: add vectura check 2026-10-29 13:23:17 -04:00
Florent BEAUCHAMP
0cb3f0a992 fix: align inventory with front 2026-10-29 13:23:17 -04:00
Florent BEAUCHAMP
93fb8bad35 fix: login error return 422 code 2026-10-29 13:23:17 -04:00
Florent BEAUCHAMP
6f8fbb57cf fix: automatic review 2026-10-29 13:23:17 -04:00
Florent BEAUCHAMP
2c63b82e84 feat(v2v): support network and disk mapping per vm 2026-10-29 13:23:17 -04:00
Florent BEAUCHAMP
fde6f8d91f feat(rest-api): POST /v2v/actions/start 2026-10-29 13:22:43 -04:00
Florent BEAUCHAMP
5ce0c65ead feat(rest-api): POST /v2v/actions/inventory 2026-10-29 13:22:43 -04:00
Florent BEAUCHAMP
843d8b0f22 feat(types): type the V2V methods of XoApp 2026-10-29 13:22:43 -04:00
Florent BEAUCHAMP
e0fd2b9a8f feat(xo-server/v2v): record the source platform of an import 2026-10-29 13:22:43 -04:00
Florent BEAUCHAMP
5cc86ea8dc feat(vmware-explorer): read the whole inventory in one pass 2026-10-29 13:22:43 -04:00
MlssFrncJrg
a50776cb7d feat(lite): 0.27.0 (#10690) 2026-10-29 17:34:12 +01:00
MlssFrncJrg
b477167b41 feat: release 9.10.0 (#10689) 2026-10-29 16:52:04 +01:00
Alina D
909fa94161 feat(lite): add possibility to edit a VIF (#10505) 2026-10-29 16:23:38 +01:00
MlssFrncJrg
79e88245da feat: technical release (#10683) 2026-10-29 10:05:20 +01:00
Pierre Donias
4802815bc3 fix(web): move runtime deps to devDependencies (#10678)
Introduced by #10450 and #10631
2026-10-29 09:23:42 +01:00
MlssFrncJrg
7985244daf feat: technical release (#10677)
* feat(@vates/iscsi): 0.4.0

* feat(@vates/types): 2.5.0

* feat(@xen-orchestra/backups): 0.78.0

* feat(@xen-orchestra/backups-cli): 1.1.18

* feat(@xen-orchestra/disk-cli): 2.2.5

* feat(@xen-orchestra/immutable-backups): 3.0.9

* feat(@xen-orchestra/web-core): 0.65.0

* feat(@xen-orchestra/mixins): 0.23.0

* feat(@xen-orchestra/proxy): 0.34.0

* feat(@xen-orchestra/vmware-explorer): 1.2.0

* feat(@xen-orchestra/qa-test): 1.4.0

* feat(@xen-orchestra/rest-api): 0.41.1

* feat(@xen-orchestra/web): 0.65.0

* feat(xo-server): 5.213.0

* feat(xo-server-sdn-controller): 1.6.0

* feat(xo-web): 5.208.0

* chore(CHANGELOG): update next
2026-10-28 16:02:08 +01:00
amouillard-vates
406f4436c6 fix(xo-lite/PIF): update forget PIF modal message (#10676) 2026-10-28 13:25:57 +01:00
MlssFrncJrg
1609d2f825 feat(xo6/xo-lite): add console clipboard to VM and host consoles (#10625) 2026-10-28 11:30:53 +01:00
Florent BEAUCHAMP
a953ca748d fix(web-core/console): stop reconnecting the console on every VM update (#10672)
`VtsRemoteConsole` watched `[consoleContainer, …]` where `consoleContainer`
comes from `useTemplateRef`, i.e. a shallow ref. Vue forces a multi-source
watch to run its callback when one of the sources is a shallow ref, even if
no value has changed. The parent page builds a new (identical) `URL` object
on each VM update, so every SSE update tore down and recreated the VNC
connection.

This was mostly visible while a VM boots (Windows especially), because the VM
gets updated every few hundred milliseconds then.

Watching `() => consoleContainer.value` restores the normal value comparison.
2026-10-28 11:12:49 +01:00
Elise-F
5385f9f229 feat(xo6/VM): migrate action (#10615)
See XO-1758

The migration drawer has two steps:
1. Select the destination host (and optionally the migration network).
2. Map the VM disks to SRs and the VM interfaces to networks on the destination.
2026-10-28 10:59:25 +01:00
Killian
092133f811 feat(sdn-controller): traffic rules ordering (#10525) 2026-10-28 10:19:02 +01:00
spacotte-vates
54c52e8f24 feat(cli): Add disable-remote command (#10587)
* fix(remotes): improve unresponsive remote handling
2026-10-28 10:05:11 +01:00
OlivierFL
9e1fc78f84 feat(web-stack): complete translations of locales below 70% (#10641) 2026-10-28 09:24:55 +01:00
Florent BEAUCHAMP
e2ca66130c feat(live-mount): optional local VDI cache, making the mount read/write (#10434)
* refactor(mixins,proxy,xo-server): pass the XAPI names as one xapiLabels object

The SR name was a lone `nameLabel` threaded through xo-server, xo-proxy and
the mixin; the VDI names are about to join it. No change in behavior: the
proxy API still takes `nameLabel`.

Also export OC_MOUNT from _utils.mjs, the cache module will need it.

* feat(iscsi): FileBlockDevice can serve a block device node

With `size`, the path must be a block device (e.g. a VDI hot-plugged onto the
appliance, /dev/xvdX): its inode reports a size of 0, so the capacity comes
from the caller, and a short read is an I/O error instead of a sparse tail.

Also add unit tests for FileBlockDevice, which had none, and integration
tests against a loop device.

* feat(iscsi): advertise read-only LUNs as write protected

A read-only BlockDevice now sets the Write Protect bit of MODE SENSE, so the
guest mounts the disk read-only instead of failing on its first write. This is
the case of a live mounted disk, served by a read-only DiskBlockDevice.

* feat(live-mount): name the SR and VDI after the backed up VM and restore point

The SR is now `[XO live mount] <VM> (<backup date>)` and the VDI keeps the
name of the backed up disk, so a live mounted disk reads like the disk the
user knows. The VDI is also tagged with `xo:live-mount`, like the SR.

The proxy API takes them as `xapiLabels`; `nameLabel` is still accepted from
older XOs.

* feat(live-mount): optional local VDI cache, making the mount read/write

Without a cache, a live mounted disk is read-only and every read goes to the
backup repository. With `cacheSrId` (xo-server) / `cacheSr` (proxy API), the
disk is materialized block by block into a VDI of that SR, hot-plugged onto
the appliance serving the mount (XOA or the proxy):

- a block is read from the backup repository at most once;
- the LUN is read/write, writes land in the cache VDI and the backup is never
  modified;
- the VDI is created with the mount and destroyed with it: what was written
  is lost on unmount.

The appliance must be a VM of the target pool, and the SR plugged on the host
running it. On a local SR, the appliance must not be migrated while the mount
lasts, since the cache VDI cannot follow it: this is documented, not enforced.

The cache VDI is tagged `[NOBAK] [NOSNAP]` to stay out of the appliance's own
backups, and the SR of a cached mount is named `[XO live restore] …`. The
cache device is opened with retries, since its node may lag behind the
hot-plug.
2026-10-28 09:00:03 +01:00
vates-bot
d618707ac3 feat(web-stack/i18n): update Xen Orchestra 6 (#10464) 2026-10-28 08:53:29 +01:00
OlivierFL
2685f3b95c feat(xo-6/pool-dashboard): refactored duplicated progress bar groups components (#10386) 2026-10-27 18:14:02 +01:00
Marie Garde
4cfe9e81bd feat(xo6): refacto backup folder in backup-job and backup-log (#10636)
* refacto backup job

* refacto ok

* refacto for PR

* rebase ok

* fix bild
2026-10-27 15:58:38 +01:00
Marie Garde
3359eb898f feat(xo6): add backup job side panel (#10631)
* feat(xo6): add backup job side panel

* wip refacto for merge

* refacto for merge

* add tests

* refacto ok

* last refacto

* fix report when field

* fix post rebase
2026-10-27 15:33:30 +01:00
Marie Garde
1ce24b85bd feat(xo6): Add backup job list (#10630)
* add table

* cards info and schedule

* card last 3 runs ok

* add backed up VMs

* add card backup target

* setting card ok

* add cron next schedule

* add warning 2 schedule

* fix style recipient list

* fix style cron

* split into 2 branchs

* refacto for PR

* add tests

* refacto for merge

* last refacto
2026-10-27 14:55:48 +01:00
Joris K
c234800468 feat(lite): add host side panel in pool page (#10580) 2026-10-27 14:21:44 +01:00
Pierre Donias
a846ae68dd feat(xo6/vm): warn that deleting a VM deletes its disks (#10554) 2026-10-27 12:17:18 +01:00
Joris K
666a99706e feat(xo6): add page plugins (#10593)
* feat(xo6): add page plugins

* improve error summary component

* plugin configuration form review

* fix(xo6): adapt plugins to master after rebase

- FrontXoPlugin keeps the plugin types of @vates/types (strict union since #10588)
- tests follow the state checks, task params and loading state of the master backend

* chore(xo6): add plugins page to the changelog

* fix(xo6): pluralize the examples count and unprefix generic plugin i18n keys

* fix(xo6): drop JSDoc from the json-schema form

* test(xo6): align the plugin tests with the existing test patterns

Component tests mock the plugin jobs and assert their run and arguments instead of the REST requests, job tests mock fetch.util directly with run/validate blocks, composable tests use a local mockJob, and createPlugin gets the factory JSDoc.

* refactor(xo6): name the json-schema form conditions and share the object schema guard

* refactor(xo6): expose the summary errors from the json-schema form

* refactor(xo6): name the plugin save and test steps

* fix(xo6): use the delete action icon in the plugin delete buttons

* revert(xo6): drop the capitalize prop of UiToggle

* refactor(xo6): remove the go-to-first-error button of the plugin configuration

* refactor(xo6): use VtsDeleteButton in the plugin configuration deletion

* revert(web-core): keep useValidatedForm and useFormSelect as on master

The plugin forms focus their first missing field through useJsonSchemaForm, so the
error summary and focus-on-validate of useValidatedForm had no consumer and changed
the behaviour of the existing forms.

* feat(web-core): use the puzzle icon for plugins and the window icon for templates

* feat(xo6): plugin load and startup actions in the plugins table, panel and header

- Side panel: Load/Unload button + "Enable/Disable load at startup" in more actions,
  loaded / load at startup as statuses, copiable version and description, no bottom link button
- Table: action column (select + quick actions) replaces the select column
- Detail head bar: same actions, PluginStateToggles removed
- One plugin.change-state job replaces the load and unload jobs; updatePluginAutoload
  and isPluginLoaded move to xo-plugin.util.ts
- useXoPluginUtils no longer exposes description
- Shared test helpers: createJobResult, createActionItem, findTooltipContent

* feat(xo6): read-only plugin detail page, configuration and test in drawers

- Detail page: general info + schema-driven read-only configuration card (VtsColumns)
- Configure: primary action in head bar, side panel and table row, opens a drawer
- Test: drawer from the more-actions menus; delete configuration in the same menus
- Errors follow the app pattern: the drawer stays open on job failure
- Delete configuration is blocked while the plugin is loaded (the API rejects it)
- Secrets: masked by xo-server's name rule or by the value obfuscated by the API
- web-core: VtsActionItems extracted from VtsActionsMenu for menu-slot reuse

* feat(xo6): show the plugin Configure action first, next to Load

* refactor(xo6): remove the unused ApiErrorAlert, useActionError and api-error utils

* refactor(xo6): de-duplicate the plugin drawer composables and test fixtures

- one useXoPluginDrawerJob replaces the near-identical configure and test composables
- useXoPluginUtils exposes the description and loaded state used by the templates
- shared plugin schema fixture and JSON schema item-label test helpers

* refactor(xo6): de-duplicate the JSON schema fields and the plugin tests

- JsonSchemaInputWrapper holds the input wrapper, message and validation of the input, select and XO object fields
- JsonSchemaField wraps the object and array fields in their fieldset, which owns the optional value
- shared test helpers for drawers, plugin actions, titles and JSON schema forms

* refactor(xo6): reuse @vates/obfuscate and cover the JSON schema select fields

- @vates/obfuscate exports isSensitiveParam and ships its types; XO 6 imports it and OBFUSCATED_VALUE instead of copies
- tests for the select, XO object field and useXoObjectOptions; drop the select guard that could never run
- JsonSchemaValue titles its sections in a computed and checks the secret mask

* feat(xo6): show the arrays of a plugin configuration as a list

* refactor(xo6): share the plugin route and the item label type, remove the unused required key

* docs(lite): add a story for VtsActionItems

* feat(xo6): validate the numeric limits, integers and list sizes of the plugin forms

The JSON schema form only checked the required fields, so a value under minimum, over maximum, a decimal number for an integer or a list shorter than minItems was only rejected by the server. oneOf is still left to the server.

* test(xo6): share the drawer, button and tag test helpers, test the preset application

Replaces the local submit, button and tag lookups of the backup repository, plugin, host and VM tests with the src/test helpers, makes PluginMoreActions assert its wiring instead of its mock, and documents the new helpers.

* test(xo6): test the plugin form lists, sections, messages and links, extract the property grouping

* fix(xo6): do not test a plugin while it is being loaded or unloaded

* refactor(xo6): give fetchPut and fetchPatch no default type, like fetchPost

* refactor(xo6): identify a missing plugin in the plugin job argument instead of in defineJob

* refactor(xo6): remove the unused disabled prop from the JSON schema form

* refactor(xo6): detect the plugin secrets in the front only, leave @vates/obfuscate as on master

- the secret property names and the obfuscated value are local to XO 6 again, no @vates/obfuscate export, types or release
- the backup repository and plugin tests import the obfuscated value from the shared constants

* refactor(xo6): use checkboxes instead of toggles in the plugin forms

* test(xo6): get a checkbox by its label with a shared test helper

* test(xo6): reset the state of the array items when the list is replaced from the outside

* refactor(xo6): split the JSON schema form dir into the plugin components, utils, composables and form dirs

---------

Co-authored-by: Olivier Floch <olivier.floch@vates.fr>
2026-10-27 11:42:45 +01:00
Alina D
5dd65197bb feat(xo6): display correct date and time format in all UI (#10547) 2026-10-27 11:01:39 +01:00
Florent BEAUCHAMP
c2bda04112 feat(v2v): enable CBT automatically + use soft shutdown when possible (#10637) 2026-10-27 10:24:16 +01:00
Sebastien Marie
0be905e7cf fix(xo-server-sdn-controller): clean up partial state on network registration failure
When privateNetwork.addNetwork() fails during xapi connection, the network is 
already inserted in privateNetwork.networks and a center may have been set 
without being recorded in _starCenters, leaving a broken PrivateNetwork 
registered.

After the registration loop, remove the network that failed to register, drop 
the orphaned center, and delete the PrivateNetwork once it has no network left, 
like _handleDisconnectedXapi already does.

Also defer pushing to noVniNetworks until the network is registered, so a failed 
network gets no VNI and does not trigger _electNewCenter outside of any 
try/catch.
2026-10-26 17:54:01 +01:00
Sebastien Rodot
fd5b54f972 fix(xo-server-sdn-controller): CHANGELOG.unreleased.md
add CHANGELOG.unreleased.md entry for GPN reliability issues.
2026-10-26 17:54:01 +01:00
Sebastien Rodot
248a6e025c test(xo-server-sdn-controller): Add reliability tests for stale center and unplugged PIF
- handles a stale preferred-center host gracefully
- handles PIF unplugged when center is undefined
2026-10-26 17:54:01 +01:00
Sebastien Rodot
d4dd0791f2 fix(xo-server-sdn-controller): Guard center.$ref access in _pifUpdated and _hostUnreachable
Avoid to dereference privateNetwork.center without checking it first.
2026-10-26 17:54:01 +01:00
Sebastien Rodot
1329b7bc69 fix(xo-server-sdn-controller): Wrap per-network processing in try/catch
Wrap the entire per-network registration body (after the privateNetwork creation 
check) in a try/catch so one network's failure doesn't affect others.

Reorder `xo:sdn-controller:vni` to after `privateNetwork.addNetwork()` call to 
manage more easily the undo.
2026-10-26 17:54:01 +01:00
Sebastien Rodot
b9fa292b9d fix(xo-server-sdn-controller): Wrap preferred-center lookup in try/catch
The value stored for preferred-center could be wrong or out-of-date. If no XAPI 
object exists with such uuid (the host designed as preferred center was 
removed from XAPI), avoid to hard fail, and consider the value is absent.
2026-10-26 17:54:01 +01:00
Mathieu
767fc2cb76 fix(xo-server): fix cannot read properties of undefined (#10668)
Introduced by e58587aa1d
2026-10-26 11:02:03 +01:00
Bastien Nollet
d2ffe1ad56 feat: technical release (#10667) 2026-10-23 12:04:08 +02:00
Bastien Nollet
a508ca7897 fix(xo-web): limit concurency on mass orphan VDI deletion (#10663) 2026-10-23 10:28:26 +02:00
Mathieu
96e789c35e feat(@xen-orchestra/web): backup-log and backup-archive collection use SSE (#10604) 2026-10-23 10:18:41 +02:00
amouillard-vates
f013adf7e4 feat(lite): add ability to forget a PIF from the network table (#10239) 2026-10-23 10:09:45 +02:00
Bastien Nollet
3923756ff5 fix(backups): delete orphan VDIs (#10546)
XO-2839
2026-10-22 12:16:03 +02:00
Killian
988d466cba feat(backups) : sets the backup archives as a collection and streams changes as they happen (#10567)
The `backup-archive` SSE collection (#10472) only announced a change when a client listed the
repository: XO never read one on its own, so a subscriber learnt of a backup created, merged or
deleted at the next listing by any client, and after a restart heard nothing from a repository
until a client listed it.

XO now reads the repositories itself:
- every enabled repository is listed once XO has started, one after another, and a repository
  enabled later as soon as `updateRemote()` has stored it;
- the changes this XO makes are pushed at once, for a repository whose journal can be replayed: at
  the end of each VM export to it (worker, no-worker mode and proxy alike), at the end of the job
  and after a deletion. A read in progress when `refresh()` is called reads again, since it may
  have missed the change, at most 3 times and never when that means listing the repository in full;
- the other changes (the size the merge worker rewrites, another XO sharing the repository) are
  read by two timers, 2 repositories at a time and through the listing backoff: every minute from
  the journal, every 10 minutes by a full listing for a repository whose journal cannot be read,
  e.g. behind a proxy which does not expose it;
- a repository is listed in full again an hour after its entry was built, rather than when it
  crosses a UTC day, which bounds the drift from what the journal does not record. The
  `fullListingInterval` parameter of its URL sets this delay, in whole minutes; the XO 6 and XO 5
  repository forms now keep it when the repository is edited.

The archives are registered through `registerCollection` like any other collection, which drops
the REST API special case for them. Their events now carry an array of archives (`add`, `update`)
or of ids (`remove`); SSE subscribers still receive one event per archive.
2026-10-22 11:41:10 +02:00
Thierry Goettelmann
e0c9112429 feat(web-core): add UiMenuItem component (#10642) 2026-10-22 10:53:45 +02:00
Marie Garde
61f3cbc9ce feat(xo6): add multi edit BRs (#10555)
* rebase ok

* tests ok

* fix build
2026-10-22 10:05:17 +02:00
Marie Garde
eac0b1e2c9 feat(xo6): add actions on BRs (#10532)
* feat(xo6): add actions on BRs

* change monitorTask

* actions jobs ok

* fix monitorTask and add actions buttons on detail and side panel

* action bar wip

* table actions change state ok

* multi test speed ok

* add type to confirm modal

* forget ok and create new component bulk

* fix tests

* refacto for PR

* refacto for PR

* refacto for PR ok

* refacto for merge

* add and update tests

* last refacto
2026-10-22 09:55:46 +02:00
Thierry Goettelmann
5a384d60c4 fix(web-core): show state hero illustrations in color in Safari and Firefox (#10639)
Introduced by a883cef0d6
2026-10-21 15:21:02 +02:00
Florent BEAUCHAMP
bc927fdffb fix(xo6): wrong ETA for task , show transferred size for backup and v2v tasks (#10622)
* feat(xo6): add size on task quick view
* feat(v2v): show transferred size in task
2026-10-21 13:46:00 +02:00
Elise-F
7c3f861a98 feat(rest-api): add bypassCpuCheck parameter to VM migrate action (#10614) 2026-10-21 11:29:46 +02:00
Joris K
0ebc92b50f feat(rest-api): add endpoint to plugins (#10588)
* feat(rest-api): add endpoint to plugins

* add changelog

* review

* mathieu's review

* remove patch endpoint

---------

Co-authored-by: MathieuRA <contact@mathieu-raisin.fr>
2026-10-21 11:25:06 +02:00
Florent BEAUCHAMP
57282c0457 feat(backup/incremental mirror): don't recompress block if the compression is the same on source and destination (#10581)
* feat(backup/incremental mirror): don't recompress block if the compression is the same on source and destination
2026-10-21 11:07:43 +02:00
Mathieu
ee54d78259 docs(rest-api): add some guideline for stream endpoint (#10628) 2026-10-21 10:03:43 +02:00
Florent BEAUCHAMP
10208cbfe0 feat(vhd-lib): add fast custom compression removing only zero chunks (#10557)
typical performance of this filter is 1% cpu at 1GB/s, compression level is 20-40% size reduction depending on data
2026-10-20 15:56:20 +02:00
Florent BEAUCHAMP
6f5caa409c feat(backup): support zstd compression for vhd blocks (#10545)
XO-3133
2026-10-20 15:21:47 +02:00
Florent BEAUCHAMP
2233bad7a7 fix(qa-test): don't try to export disk > 2TB in vhd (#10607)
* fix(qa-test): don't try to export disk > 2TB in vhd
2026-10-20 14:17:45 +02:00
vmpr
b630456835 fix(xo-server-transport-icinga2): send credentials via auth instead of URL (#10594)
Fixes #10498
Introduced by 3293e69cc2
2026-10-20 12:48:35 +02:00
MlssFrncJrg
c77d22f422 feat(xo-server): persist backup repository size on the remote object (#10406) 2026-10-20 09:48:00 +02:00
Mathieu
191317630d feat(vectura): read disks from ESXi 6.x hosts (#10590) 2026-10-19 16:50:18 +02:00
ayoub-el-kajji-v
d32c9879b1 fix(hosts): multipathing should not require disconnecting srs on local storage (#10592)
XO-2336
2026-10-19 16:07:07 +02:00
Thomas Moraine
ab4d372ce2 docs(v2v): replace VDDK screenshots with vectura ones (#10620)
Update the prerequisite check and transfer form screenshots, and remove the unused VDDK/nbdinfo ones.
2026-10-19 14:23:11 +02:00
Pierre Donias
5cc6500def fix(lite/vm-creation): fix MAP_DUPLICATE_KEY on cores-per-socket (#10552)
Introduced by #8491
See #10201 (same workaround)

`VM.set_VCPUs_at_startup` ran concurrently and re-added `cores-per-socket`
between its removal and its re-addition, making the VM creation fail.
2026-10-19 14:14:05 +02:00
Olivier Lambert
8a70c4abc0 docs(xoa): align sizing guidance with the deploy form presets (#10617)
The deploy form now offers Small (2 vCPUs, 4 GiB), Medium (4 vCPUs,
8 GiB) and Large (8 vCPUs, 16 GiB, default) presets. Update the sizing
table to match, and drop the manual --max-old-space-size procedure:
since 6.8, xo-server sets its heap limit to 70% of the VM RAM.

Signed-off-by: Olivier Lambert <olivier.lambert@vates.tech>
2026-10-19 13:28:32 +02:00
Elise-F
1639a5f6a6 fix(xo6): opening console VM in new tab (#10616) 2026-10-19 11:35:34 +02:00
Olivier Lambert
d525a7d5bc feat(@vates/iscsi): bound what a peer can make the target do (#10566)
* fix(@vates/iscsi): only replace the session once the newcomer has logged in

A new TCP connection used to tear down the active session as soon as it
was accepted, before sending anything. Any peer able to reach the port,
a port scan, a failed CHAP login or a Discovery session, interrupted the
disk in use.

New connections are now kept aside until they establish a Normal session
(CHAP included when configured), and only then replace the active one.
That keeps the ability to recover from a stale connection, which is why
connections are replaced rather than refused.

Connections without a session are bounded in time (`loginTimeoutMs`,
15 s by default, also closing lingering Discovery sessions) and in number
(`maxPendingConnections`, 8 by default, oldest dropped first).

* chore: add PR number to changelog

* fix(@vates/iscsi): refuse extra connections and guard the login race

Review follow-up:
- let net.Server refuse connections past maxPendingConnections (+1 for
  the one serving the LUN) instead of evicting the oldest pending one
- a connection destroyed while its final Login Response is being written
  must not go on and replace the current session

* feat(@vates/iscsi): bound what a peer can make the target do

- allowedAddresses: close connections from other initiators as soon as
  they are accepted, so that they cannot take the pending connection
  slots and block a legitimate login
- reject a PDU whose data segment exceeds 8 KiB during login, or the
  declared MaxRecvDataSegmentLength afterwards, before reading it
- maxTransferLength (8 MiB by default): advertised in the block limits VPD
  page, longer READ/WRITE refused, which also bounds the WRITE buffer
- BlockDevice.readOnly: report the LUN write-protected and refuse WRITE
  with DATA PROTECT before soliciting any data; DiskBlockDevice sets it

* chore: add PR number to changelog

* feat(@vates/iscsi): rename allowedInitiatorAddresses, report refused connections

`allowedAddresses` did not tell whose addresses: these are the source
addresses of the initiators. `onConnectionRefused` reports each refused
one, e.g. so that a caller can tell that an initiator connects through a
NAT.

---------

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.tech>
2026-10-19 10:03:00 +02:00
Mathieu
776993e119 fix(vates/types): fix vm and server type (#10598)
XO-3258
fix #10514
introduced by 0ef95d9 and b078d3d
2026-10-19 09:59:23 +02:00
Pierre Donias
8ae4ea25b2 feat(netbox): support Netbox v4.7.x (#10574)
Fixes #10565
See https://github.com/netbox-community/netbox/releases
2026-10-19 09:21:11 +02:00
Thomas Moraine
eecba81f22 docs(readme): replace XO 5 screenshots with XO 6 ones (#10600)
The last screenshot (SR statistics) is kept from XO 5, as XO 6 has no equivalent stats view yet.
2026-10-17 11:51:40 +02:00
Florent BEAUCHAMP
05aeff101b fix(immutable-backups): fix test not waiting for immutability (#10606) 2026-10-16 17:09:48 +02:00
spacotte-vates
b7af59b6ed feat(events): improve xo5 event handling (#10568)
XO-3152

Improves XO5 event handling

Event improvements - Front end test.md

Event improvements - Performance impact.md
2026-10-16 16:38:04 +02:00
Thierry Goettelmann
12e1a10394 fix(@xen-orchestra/acl): drop node:assert dependency (#10608)
node:assert is not available in the browser, so importing @xen-orchestra/acl
from a front-end bundle failed. Input checks now throw plain TypeErrors.
2026-10-16 14:24:08 +02:00
Killian
954a2034e6 test(@vates/iscsi): wait until the open-iscsi disk can be opened (#10576)
The CHAP interop test failed on CI with ENXIO on /dev/sdb (master 641f98878,
and #10549), right after waitForDevice() returned the disk.

waitForDevice() took a nonzero /sys/class/block/<name>/size as proof the disk
was ready, but the sd driver sets the size before device_add_disk(), which
creates the sysfs entry, then the /dev node, then registers the block device:
open() fails with ENOENT or ENXIO until then. Polling sysfs in a tight loop
after login hit that window in 15 of 100 logins.

isDeviceReady() now opens the device, and treats ENXIO and ENOENT as not ready
yet.
2026-10-16 11:35:10 +02:00
Thierry Goettelmann
b819ba1527 feat(web-core): make query builder model live and add search event (#10602) 2026-10-16 11:02:49 +02:00
Mathieu
ff90a80e99 feat(rest-api): expose backup-log in the SSE (#10563)
* feat(rest-api): expose backup-log in the SSE

* noop instead undefined

* fix: handle sequance

* changelog

* fix

* final fix

* emit remove event from the gc

* Kilian's feedback

---------

Co-authored-by: Killian <killian.allegrain@vates.tech>
2026-10-16 10:59:12 +02:00
Florent BEAUCHAMP
cfa75ef652 feat(xo6,rest-api): export vdi as vmdk (#10591) 2026-10-16 10:57:42 +02:00
Florent BEAUCHAMP
a350c62cc3 feat(fs, backups): don't concatenate buffers but write them directly (#10538)
* feat(fs, backups): don't concatenate buffers but write them directly
maximum throughput with a perfect source and no write goes from
4.5 to 9.6GB/s

* fix: remove Buffer concat in qcow2 stream generationonly needed for last block
2026-10-16 10:57:00 +02:00
Florent BEAUCHAMP
bb5a79e6c6 feat(xo-server, xo-web): support insecure nbd (#10540)
* feat(nbd-client): support nbd insecure networks
* feat(xo-web): support insecure nbd

also add a confirm on insecure enabling
The dialog is titled "Enable insecure NBD?" and says: "The disk data of the backups will travel unencrypted on this network. Only enable it on a network dedicated to backups and isolated from the VMs and the users (dedicated VLAN or physical network)." Both texts are new entries in messages.js, and the dialog itself is a new confirmInsecureNbd() helper in xo/index.js.

It appears in two places:

Pool Network tab (tab-network.js):
The dialog only appears when switching a network to insecure NBD. Reselecting it on a network that already has it, or switching to "NBD" or "No NBD", doesn't ask.
On cancel, nothing is sent and the drop-down keeps showing the network's current setting.
Network creation form (new/network/index.js):
The dialog appears on "Create" when insecure NBD is selected (bonded and private networks are never affected).
On cancel, the network isn't created and the form stays as is, with no error and no redirect.

* feat: update xo5,xo6 and lite ui for this settings

---------

Co-authored-by: MlssFrncJrg <119158464+MelissaFrncJrg@users.noreply.github.com>
2026-10-16 10:45:54 +02:00
Pierre Brunet
e4e7c05104 fix(fs/s3/azure): better handling of NotFound in _getSize and _unlink (#10548)
* fix: handle error codes

* chore: update changelog

* fix: apply suggestions from code review

Co-authored-by: spacotte-vates <simon.pacotte@vates.tech>

* fix: small typo

---------

Co-authored-by: spacotte-vates <simon.pacotte@vates.tech>
Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.tech>
2026-10-15 18:16:51 +02:00
Marie Garde
52c68a28cc feat(xo6): add edit form BR (#10478)
* feat(xo6): add edit form BR

* type disable

* wip

* fix post rebase

* form initial data ok

* form ok

* add tests dans finalize edit

* refacto for PR

* refacto for RP

* rebase ok

* feedback

* PR ok

* add changelog
2026-10-15 17:26:44 +02:00
Marie Garde
635f02bcae feat(xo6): Add backup job list (#10572) 2026-10-15 16:39:32 +02:00
Olivier Lambert
db5cb01fc9 fix(xo-server): cancel the disconnection grace period when the connection drops (#10577) 2026-10-15 16:15:00 +02:00
Florent BEAUCHAMP
928408fe8e feat(vmdk): first version of the package with streamoptimized export (#10403) 2026-10-15 15:14:34 +02:00
Mathieu
db98f15bc1 docs(rest-api): add a naming section in guidelines (#10571) 2026-10-15 13:58:39 +02:00
Mathieu
decdf61919 fix(vectura): update rustls to 0.23.46 (#10586)
cargo deny fails on master since RUSTSEC-2026-0346, 0347, 0348 and 0349
were published against rustls 0.23.45. 0.23.46 fixes all four.

vectura has no custom session store, no ECH and no brotli feature, so
only 0349 (later connections downgraded to classical key exchange through
the resumption cache) applies, in theory.

vectura 0.1.3, bumped and rebuilt with scripts/release.sh patch.
2026-10-15 11:26:00 +02:00
Thierry Goettelmann
6115c74bbe fix(web-core): keep an open select's Escape from closing its overlay (#10582) 2026-10-15 10:48:37 +02:00
Mathieu
025f6eb533 feat(vectura): bump and build in the pull request, not at release (#10573)
The binary in git is now the build of its sources. build.sh keeps the paths
of the machine out of the binary and refuses another zig or cargo-zigbuild
version, and the Vectura workflow fails when its build differs from the
committed binary. The binary is rebuilt with this build.sh: only its paths
change, the version stays 0.1.2.

vectura is no longer released on its own. release.sh runs once at the end of
the pull request which changes vectura and no longer commits. bump-pkg,
gen-deps-list.js and check-changed-pkgs.js drop the vectura pseudo-package: a
change under vectura/ requires a release of @xen-orchestra/vmware-explorer,
which ships the committed binary as is. release.sh puts Cargo.toml and
Cargo.lock back when the build or the check of the binary fails.
2026-10-15 10:43:00 +02:00
Thierry Goettelmann
3e74189479 fix(web): generate route-map.d.ts only in development mode (#10583) 2026-10-15 10:34:37 +02:00
Thierry Goettelmann
0695c6b44c fix(web-core): hide the focus outline of readonly inputs on Firefox (#10584) 2026-10-15 10:31:29 +02:00
Bastien Nollet
0bb2f12b2b fix(xo-server): avoid sequence abort on error with proxy (#10504) 2026-10-14 12:30:05 +02:00
Thierry Goettelmann
865b6a84c7 fix(web-core): use solid border and body typography in UiTextarea (#10579) 2026-10-14 09:34:13 +02:00
Thierry Goettelmann
6b82340fdc fix(web-core): balance remote resource subscriptions on url and isEnabled changes (#10473) 2026-10-14 08:59:26 +02:00
Marie Garde
d846e00dff feat(xo6): BR add detail page (#10454)
* feat(xo6): BR add detail page

* Prepare PR

* add tests

* fix breadcrum

* fix post rebase

* fix admin path

* fix link side panel

# Conflicts:
#	@xen-orchestra/web/src/modules/backup-repository/components/list/panel/cards/BackupRepositoryInfosCard.unit.test.ts
#	@xen-orchestra/web/src/modules/backup-repository/components/list/panel/cards/BackupRepositoryInfosCard.vue

* feedback

* feedback

* rebase ok
2026-10-14 08:50:18 +02:00
Florent BEAUCHAMP
f93bb67409 fix(xen-api): really throw import error while importing VM (#10551) 2026-10-13 17:13:44 +02:00
Florent BEAUCHAMP
51bb88a1ce feat(vmware-explorer): method to list/attach/detail disk to satellites (#10449)
* feat(vmware-explorer): implement attaching and detaching disks
* feat(vmware-explorer): implement a check to see if a disk can be attached to a VM
* feat(vmware-explorer): implement a method to detect what host are candidates to deploy a satellite
* refactor: computing the datastore of a datapath
2026-10-13 13:58:23 +02:00
Pierre Donias
11335b465b fix(xo-web/self): fix removing a tag from a resource set (#10550)
Fixes Zammad#65918
2026-10-13 11:05:35 +02:00
Killian
295b8838a6 docs(backups): explain when reverse replication updates the VM in place (#10558) 2026-10-13 10:01:20 +02:00
Pierre Briffaux
e0b0c75682 feat(xo6): add copy button for multiple information (#9976) 2026-10-13 09:16:35 +02:00
Thomas Moraine
286dafa267 docs: document warm migration and fix broken link in architecture page (#10570) 2026-10-12 18:20:37 +02:00
Thierry Goettelmann
b08ad1669f feat(web-core): add UiLinkList component (#10287) 2026-10-12 17:36:54 +02:00
Thomas Moraine
4731cbd629 docs(advanced.md): document estimated per-VM power consumption metric (#10569) 2026-10-12 17:13:27 +02:00
Florent BEAUCHAMP
1c4890be10 fix(xo-cli): error when using output on a rest api query (#10541) 2026-10-12 16:59:56 +02:00
Pierre Donias
4abb9d80bc feat(xo-web/patches): warn when pool master is not up to date (#10523) 2026-10-12 16:24:18 +02:00
Mathieu
87fe4e2b53 docs(rest-api): enhance REST API guideline (#10416) 2026-10-12 15:37:17 +02:00
ayoub-el-kajji-v
6058f07813 feat(api-rest): add sr probe endpoints (#10099) 2026-10-12 14:43:07 +02:00
Sebastien Rodot
d451d5c35b feat(xo-server-sdn-controller): add unit tests
- check that objects listeners are properly attached/detached on Xapi connect/disconnect
- check that xapi instance doesn't leak on reconnect
2026-10-12 14:34:55 +02:00
Sebastien Rodot
375b9fe91b fix(xo-server-sdn-controller): avoid leaking listeners on disconnect
Objects listeners are added to Xapi objects by the sdn-controller plugin, but 
they are only removed at plugin `unload()` time.

Prefer to remove them at Xapi disconnect time.
2026-10-12 14:34:55 +02:00
Sebastien Rodot
d53294f15c fix(xo-server-sdn-controller): re-watch XAPI events after reconnect
When a pool's XAPI connection drops and reconnects, xo-server destroys the
old Xapi instance and creates a new one (same pool UUID, same XAPI object
$refs). The plugin tracked managed pools by pool UUID, so _manageXapi()
short-circuited on reconnect and never attached its add/update/remove
listeners to the new Xapi instance.

From then on the plugin was blind to XAPI events: re-applying VIF traffic
rules on VM start, handling VIF/PIF plug and star-center election all
stopped silently until the plugin was reloaded.

Track the managed Xapi instances instead of pool UUIDs, and drop a dead
instance on disconnect so its replacement is managed again.
2026-10-12 14:34:55 +02:00
Sebastien Rodot
40e5ca9a73 fix(xo-server-sdn-controller): recreate stale OpenFlow controllers
When a XAPI connection drops and reconnects, xo-server creates a new
Xapi instance while the XAPI objects (and their $refs) are kept. The
ofChannels cache still held the controllers of the previous
connection, so traffic rule add/delete calls were silently skipped
without reaching the host.

Rework _getOrCreateOfChannel() to key the cache on the Xapi instance:
entries from a previous connection are dropped and the controller is
recreated, concurrent calls share the same instantiation, and a
failed instantiation clears the cache.

Also drop the cached controller when the host is disabled or its XAPI
is not connected, so it is recreated as soon as it comes back.
2026-10-12 14:34:55 +02:00
ayoub-el-kajji-v
cf760d26cd feat(backup): support ndb through http proxy (#10544) 2026-10-12 11:23:29 +02:00
Thierry Goettelmann
d4b85421fe fix(web-core): make query builder usable inside a form (#10475) 2026-10-12 09:59:46 +02:00
Thierry Goettelmann
61a06e817b feat(web-core): add queryFilter rule and backTo to multi-step form (#10477) 2026-10-12 09:31:32 +02:00
Thierry Goettelmann
1b1ae506b6 feat(web-core): add checkbox column to host, SR and VM column sets (#10485) 2026-10-12 09:28:58 +02:00
Thierry Goettelmann
641f988781 feat(web-core): add ignoreRoute option to table pagination and query builder filter (#10476) 2026-10-12 09:27:43 +02:00
Thierry Goettelmann
b6b94a80b1 refactor(xo6): extract VM, host and SR query builder schemas into composables (#10488) 2026-10-12 09:25:17 +02:00
Florent BEAUCHAMP
70b0824dc4 feat(backups): improve full mirror backup performances (#10537)
XO-3135 and XO-3134
2026-10-09 19:04:29 +02:00
Marie Garde
4a9d434559 feat(xo6): add emergency shutdown button host (#10412) 2026-10-09 08:42:23 +02:00
Alina D
341bd0301d feat(xo6/new-vm-form): allow resizing existing template disks when a … (#10359) 2026-10-08 20:13:29 +02:00
Pierre Donias
70be55528f fix(lite): do not reuse window.origin protocol and port when overriding master host (#9955)
When overriding the pool master with `?master=<host>`, XO Lite kept the page's
protocol and port and only swapped the hostname (e.g. `https://proxy:8443` +
`?master=192.168.1.10` → `https://192.168.1.10:8443`). Requests sent directly to
hosts also always used the page's protocol, regardless of the master's.

Everything now follows the XAPI connection (one commit each):

1. **Master override** (`xen-api.store.ts`): the value is parsed as a URL,
   `https://` by default, port and protocol allowed. An invalid value falls back
   to the default host instead of crashing XO Lite.
2. **RRD stats** (`xen-api.ts`, `getResource`): use the XAPI protocol.
3. **VM and host consoles**: `wss:` if XAPI is `https:`, `ws:` otherwise.
4. **Unreachable hosts check**: pings hosts with the XAPI protocol, so the
   certificates modal checks the right URLs.
2026-10-08 16:08:03 +02:00
Joris K
71ff8b0d24 feat(xo6/lite): add shortcut to focus the treeview search (#10492)
* feat(lite): add searchbar in the treeview

* add changelog and improvement

* feedback

* review feedback

* feat(xo6/lite): add shortcut to focus the treeview search

* add changelog

* added mac shortcut

* feedback
2026-10-08 16:03:37 +02:00
Florent BEAUCHAMP
33caeacb49 fix(nbd-client): better handling of reconnection (#10456)
* fix(nbd-client): better handling of reconnection

don't let a failure of an already remplaced trasnport impact the
current transport
2026-10-08 14:19:20 +02:00
Elise-F
854e5dbd89 fix(xo6): add missing condition to the detach job (#10543) 2026-10-08 13:48:27 +02:00
Pierre Donias
c069b7e9c7 fix(lite/vm-creation): keep UEFI firmware when setting boot order (#10521)
Introduced by #8317

`VM.set_HVM_boot_params` replaces the whole map, so setting the boot order for a
network install or a VM without disk dropped the firmware set just before, and
the VM booted in BIOS mode.
2026-10-08 11:18:30 +02:00
Thierry Goettelmann
8338851c4c feat(web-core,web,lite): add UiPanelCardTitle component (#10289)
* feat(web-core,web,lite): add UiPanelCardTitle component

Figma's "Panel title v3" describes three formats — plain text, linked
title, title with an id — which the existing VtsCardObjectTitle only
covered partially, and which several cards had reimplemented inline with
their own flex/gap rules.

Rather than three components, the parts are independent and optional: a
`to` or `href` makes the title a link, an icon or an id is shown when
passed, and `size` replaces Figma's `level`, which described the
appearance in terms of hierarchy.

Adopting it in the side panel cards drops their local title styles, and
brings them back to the 0.8rem title-to-content gap the design system
specifies.

* chore(lite): add the ui-panel-card-title story route

The generated route-map entry was omitted when the story was added.

* fix(web): address review feedback

Task infos and warnings are arrays, so their counters read `length`
directly instead of going through `Object.keys` and a non-null assertion
that only held thanks to the parent's `v-if`.

Add the changelog entries for the side panel card titles.

See XO-2885

* feat(web-core,web,lite): migrate side panel cards to UiPanelCardTitle

Replace the remaining `UiCardTitle` usages in side panel cards with
`UiPanelCardTitle`, so every panel card title shares the same layout.

To cover the cases the old titles handled, `UiPanelCardTitle` gains
`label`, `action` and `more-actions` slots, and tighter spacing between
the title and its id.

See XO-2885
2026-10-08 11:01:20 +02:00
Elise-F
7edeb7a253 feat(lite): add detach action on host (#10520)
* update changelog file

* feedback one

* feat(host): add action reboot on host

* feat(lite): add forget action on host

* feedback

* feat(lite): add detach action on host

* update changelog

* fix modal translations

* add 'host is running' condition to the job and fix translations

* feedback
2026-10-08 10:16:29 +02:00
Mathieu
1a012bdd94 fix(web-core/web): handle SSE for non XAPI objects (#10450) 2026-10-08 09:15:10 +02:00
Sandrine Barrucand
493aa7f15d feat(xo6/pool): add validation to pool connection form (#10484) 2026-10-07 16:40:31 +02:00
Alina D
cb1e45b6b0 feat(xo6): allow removing existing (template) disks in the New VM form (#10292) 2026-10-07 16:02:11 +02:00
Killian
0ff6196e72 feat(xo-server/backups,rest-api): stream changes of the VM backup archives over SSE (#10472)
The cache now announces what a listing finds and what a journal replay
changes, as `add`, `update` and `remove` events carrying the archive and its
previous value, on an emitter the app exposes as `app.vmBackupArchives`. What
was last announced for a repository outlives its entry, so a repository read
again from scratch only announces what really changed. A repository which is
removed or disabled goes through the new `forgetVmBackupRepository()`, which
announces all its archives as removed. A listener which throws is logged and no
longer fails the listing.

The REST API adds `backup-archive` to the SSE collections. It is bound to
`app.vmBackupArchives` the way `task` is, and filtered by `backup-archive:read`
through the same path as every other non-XAPI collection.

Nothing polls the repositories: changes are pushed whenever any client lists a
repository. The first listing after a restart sends every archive it already
holds as `add`.

XO-2992
XO-2993
2026-10-07 11:58:55 +02:00
Bastien Nollet
2ee51a3694 fix(load-balancer): process tags in each pool separately & other fixes 2026-10-07 11:25:38 +02:00
Bastien Nollet
87aca3ff5e feat(load-balancer): smarter migration restrictions 2026-10-07 11:25:38 +02:00
Bastien Nollet
305e66becc feat(load-balancer): vm-to-host affinity 2026-10-07 11:25:38 +02:00
b-Nollet
d3fea1296d feat(load-balancer): various minor improvements 2026-10-07 11:25:38 +02:00
Elise-F
8c4e4d110a feat(lite): add action restart toolstack on host (#10480)
* update changelog file

* feedback one

* feat(host): add action reboot on host

* feat(lite): add forget action on host

* feat(host): Add restart toolstack action for hosts with HA disabled

* feat(icon status): Add a power status icon to the treeview and to the host header

* update changelog files

* fix rebase

* feedback
2026-10-06 15:29:41 +02:00
Florent BEAUCHAMP
cc8f039341 fix(backups): allow a deadlocked merge to recover (#10424)
* fix(backups): properly throw an error if a disk is missing from a chain to merge
but accept it if the merge was interrupted at the cleanup phase
* fix(backups): a merge with 0 or 1 disk is a warning
* fix(backups): don't try to merge chain that are out of retention
this fix will allow broken to recover after a while ( depending on
retention and full backup interval )
* fix: defense in depth against deletion
 every path this class compares it against is normalized (disk paths, alias targets), so
 it must be too: a raw path would e.g. make #cleanOrphanDataFiles fail to exclude the VDI
 directory from the data directories it sweeps, and delete every unclaimed file in it
* fix(backups): better handling of missing disks during a merge
* ensure we never delete ancestries of a differential disk
2026-10-06 15:05:55 +02:00
ayoub-el-kajji-v
59c8935e37 fix(backups): delete first not working in replication (#10452)
* fix(backups): delete old entries before starting a new replication
2026-10-06 14:52:50 +02:00
Thomas Moraine
779e448f21 docs(users): recommend enabling OTP authentication (#10539)
Strongly recommend OTP (TOTP) authentication to mitigate brute force attacks on passwords.
2026-10-06 14:43:39 +02:00
Elise-F
3a64afb226 feat(lite): add enable and disable actions on host (#10337)
* update changelog file

* feedback one

* feat(host): add action reboot on host

* add header state

* feat(lite): add forget action on host

* feat(lite): add enable and disable actions on host

* update changelog file

* fix rebase

* fix rebase

* feedback one

* add redirect after forget action
2026-10-06 13:55:14 +02:00
Marie Garde
cfdf0de9ee feat(xo6): Add create form backup repository (#10271)
* feat(xo6): create and edit backup repository form

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix post rebase

* add tests

* feedback

* feedback

* rebase ok

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:39:18 +02:00
Pierre Donias
d6e6e963bf feat(xo-web/ip-pools): show IP format example (#10522) 2026-10-06 11:09:50 +02:00
Pierre Donias
29c7c0e6f1 fix(web-core/remote-resource): clear error after a successful fetch (#10516)
Introduced by #8855

lastError was never reset, so one failed poll kept hasError true and
dashboard cards in error state until reload.
2026-10-06 11:07:05 +02:00
Joris K
72a9959a3c fix(xo6): update modal events to handle confirm actions correctly (#10417) 2026-10-06 11:03:16 +02:00
Sylvère
e5d4c665a4 refactor(web/core): tasks-related refactor for ongoing My Tasks work (#10265) 2026-10-06 10:53:01 +02:00
MlssFrncJrg
0070c7f709 fix(xo-lite/XO6): exclude ISO SR guest tools from selector in VM creation (#10430) 2026-10-06 10:36:47 +02:00
Mathieu
a84584d24e feat(rest-api): POST acl-roles support list of privileges (#10470) 2026-10-06 10:21:35 +02:00
Joris K
786610cba3 feat(lite): add search bar in the treeview (#10414)
* feat(lite): add searchbar in the treeview

* improve open close treeview, and a pool dashboard that displays a scrollbar when the screen is minimized

* add changelog and improvement

* feedback

* feedback

* review feedback

* feedback add story
2026-10-06 10:09:35 +02:00
Mathieu
08e6e1bf49 ci(vectura): only run on the changes of the branch (#10528)
GitHub filters a push on the diff with the previous push of the branch: after
a rebase on `master`, or a merge of it, this diff contains the vectura changes
landed on `master` meanwhile, and the workflow rebuilds vectura on branches
which do not touch it.

A pull request is filtered on the diff with its merge base instead, i.e. the
changes of the branch only. Pushes still run the workflow on `master`.
2026-10-06 09:27:15 +02:00
Elise-F
5999d53e0b feat: release 6.9.1 (#10533) 2026-10-05 17:18:51 +02:00
Elise-F
decc09117d feat: patch technical release (#10529) 2026-10-05 15:47:30 +02:00
Sylvère
d11cf2d2fc fix(xo6): prevent unwanted inherited route params (#10511) 2026-10-05 14:35:02 +02:00
Mathieu
abdddcf105 fix(web/web-core): fix invalid VM-snapshot's VDIs (#10510)
introduced by 57414dafce
2026-10-05 14:01:13 +02:00
spacotte-vates
8c2f38c004 fix(backups): fix synchronized backup snapshop showing as vm in task log (#10518)
* fix(backups): fix synchronized backup snapshop showing as vm in task log
2026-10-05 11:32:42 +02:00
Pierre Donias
5de02b6c19 docs(PULL_REQUEST_TEMPLATE): point to internal review process instead of duplicating it (#10517)
The review process was documented both here and in the internal PR process
doc, and the two had drifted apart. Keep the internal doc as the single
source of truth and only give external contributors what they need.
2026-10-05 09:59:05 +02:00
Florent BEAUCHAMP
440624a09c Fix(backups): incremental distributed replication (#10519)
`AggregatedIncrementalXapiWriter.checkBaseVdis()` didn't forward
`contentKeys` to its sub-writers, so the second run of a multi-SR
replication failed with `Cannot read properties of undefined (reading 'get')`.

Also adds QA tests for distributed replication and copyRetention.

Introduced by 680b81088
2026-10-05 09:54:47 +02:00
ayoub-el-kajji-v
718b348e85 fix(backups): use schedule.id instead of scheduleId in replica retention (#10491)
fix(backups): use schedule from props for replica retention instead of scheduleId
2026-10-02 17:07:55 +02:00
Mathieu
278f7d1706 fix(vectura): build against glibc 2.31 so V2V runs on Debian 11 XOAs (#10513)
XOA appliances still run Debian 11, whose glibc is 2.31. The vectura binary
bundled with @xen-orchestra/vmware-explorer was linked against glibc 2.34,
so it did not start there (`GLIBC_2.32 not found`): the prerequisite check
of the VMware import page failed and V2V imports could not run.

zig now links it against glibc 2.31. Below 2.34, libpthread and libdl are
libraries of their own, so check-binary.sh allows them. The bundled binary
is rebuilt with the same version, and the docs say Debian 11 and Ubuntu
20.04 instead of Debian 12 and Ubuntu 22.04.

Introduced by dc57333f5
2026-10-02 16:34:10 +02:00
ayoub-el-kajji-v
b59c8f3d20 fix(xo-web): disable restat-toolstack for HA_enabled pool hosts in home (#10497) 2026-10-01 17:33:54 +02:00
Joris K
7711b6d6f5 fix(xo6/lite): fix video RAM displayed in bytes instead of MiB (#10486) 2026-10-01 16:40:18 +02:00
Mathieu
b0c0d9361a fix(xen-api): fix HTTP/2 for server behind HTTP proxy (#10507)
Introduced by 11eed70
fix Forum#12502
2026-10-01 14:51:46 +02:00
Mathieu
83fdb2ed1f docs(changelog): vCenter is still supported by V2V (#10502)
The 6.9.0 entry for the VDDK removal said a vCenter address is no longer
supported. vectura reads the disks through a vCenter as well as directly
from an ESXi host; only ESXi 6.x is refused, because its data port does not
announce "SHA256 supported".
2026-10-01 09:47:14 +02:00
Elise-F
c9eaa7a617 feat(lite): 0.26.0 (#10501) 2026-10-01 08:54:52 +02:00
Elise-F
a2bd181d35 feat: release 6.9.0 (#10499) 2026-09-30 18:12:53 +02:00
Elise-F
989cd48559 feat: technical release (#10496) 2026-09-30 15:53:45 +02:00
ayoub-el-kajji-v
b2f0bfc907 fix(xo-web): disable restart toolstack button when HA enabled on pool (#10340) 2026-09-30 14:57:50 +02:00
spacotte-vates
ce26dc2231 fix(backups): prevent failed CR to accumulate replicas (#10404)
* fix(backups): prevent failed CR to accumulate replicas
2026-09-30 14:44:42 +02:00
ayoub-el-kajji-v
2bd4e2a747 fix(xo-server): disable restart toolstack on HA pools (#10344) 2026-09-30 11:31:37 +02:00
Pierre Donias
75098c3cda feat(xo-web/xo-cloud-config): require acknowledgment to enable backup to Vates servers
XO-3030
2026-09-30 11:25:49 +02:00
Pierre Donias
6f091b9380 feat(xo-web/xo-cloud-config): confirm before disabling backup to Vates servers
XO-3031
2026-09-30 11:25:49 +02:00
Elise-F
4b578d439f fix(xo-server): add reactivity on pif metrics (#10438) 2026-09-30 11:13:00 +02:00
amouillard-vates
93e9069614 feat(xo-lite): add network delete action for host internal networks (#10332) 2026-09-30 11:03:53 +02:00
Joris K
c17bcead07 fix(xo6): update transfer task size calculation (#10448) 2026-09-30 10:34:34 +02:00
Pierre Brunet
3dbf8738f1 fix(security): Add safeguard to v2v (#10489)
https://github.com/vatesfr/xen-orchestra/security/advisories/GHSA-mc4m-hc98-rhmv
2026-09-30 10:21:51 +02:00
ludalm13
f187b79319 fix(xo-server): trust forwarded headers from loopback so /v5 proxies … (#10461) 2026-09-30 10:03:34 +02:00
Bastien Nollet
0ae1cf917c fix(xo-web): fix log modal not closing (#10487) 2026-09-30 10:00:22 +02:00
Marie Garde
e287eaeb55 feat(xo6): create page backup repository list (#10247)
* feat(xo6): merge base master

* returns from PR

* fix build

* fix build

* add custom cards side panel

* return form url util

* fix build

* return form PR

* fix rebase

* Remove packages format and parse url

* fix from IA review

* wip rebase

* wip

* opencode review

* add tests utils

* Add changelog

* feedback

* feedback

* feedback

* add tests

* feedback

* feedback

* feedback

* feedback

* fix after rebase
2026-09-29 17:22:24 +02:00
Florent BEAUCHAMP
5a8393aa17 feat(xoa): auto open ufw port for livemount (#10468)
only open the specific route from source to xo / xo-proxy

port openned only live in memory : restarting ufw will drop them, thus making all the live-mount disk unavailable

doc updated
2026-09-29 17:08:38 +02:00
Florent BEAUCHAMP
323f69d773 feat(live-mount): detect disk removing and cleanup sr (#10432)
the live mount feature create a lot of SR , each with 1 disk
this PR introduce an automatic cleanup that will forget a SR as soon as the disk is removed, thus removing any user action needed to cleanup thins in the nominal case

crashing XO can leave SR pluggued
2026-09-29 17:03:57 +02:00
Alina D
a883cef0d6 feat(xo6): replace images with new SVG design (#10380) 2026-09-29 16:26:30 +02:00
Bastien Nollet
d3b3248937 docs: remove "XOA unreachable" troubleshooting section (#10469)
See #9526
2026-09-29 10:30:22 +02:00
Killian
45ca13ba5d feat(xo-server/backups): replay the journal of the repositories on a proxy (#10437)
Phase 4 of the journal-replayed VM backups cache: brings proxy-attached repositories onto the same fast/fresh listing path as directly-attached ones, so a proxy repository is also listed once and then kept up to date by replaying its journal, instead of being re-listed in full on every refresh window.

What changes

RemoteAdapter#readBackupJournalEvents() reduces a journal read to the last event of each backup and resolves the added/changed ones back to their current metadata, so a listing can be brought up to date from a single call. A backup rewritten several times costs one metadata read; one written then deleted costs none.
VmBackupsCache no longer knows how a repository is read: that now lives entirely behind a new VmBackupsSource, which reads a directly-attached repository through the local adapter and a proxy-attached one through backup.listVmBackups (cold build, reused as-is) and the new backup.listVmBackupsJournal RPC (journal replay).
The proxy exposes backup.listVmBackupsJournal, returning its journal events with the backups already formatted, so XO gets a listing update in one round-trip instead of one metadata read per file. A proxy too old to know the method is warned about once and falls back to a full listing, same as before this change; it starts being replayed as soon as it's upgraded, with no restart needed on the XO side.
The cache's staleness check now compares its own clock only against when this process last refreshed an entry (refreshedAt), never against the watermark returned by a journal read (cursor), which is stamped by whichever process wrote it — proxy or not. Comparing the two directly would silently over- or under-refresh a repository depending on clock skew.
An entry also rebuilds from scratch if its repository moves to or from a proxy, or if the source reports it can no longer be replayed (e.g. an old proxy), rather than being purged outright.
The old proxy-only listing path in BackupNg (_listVmBackupsOnProxy, bypassing the cache) is removed: proxy and local repositories now go through the exact same cache.
A single corrupt/unreadable metadata no longer fails a whole journal read; it's kept behind the watermark so only that entry is retried on the next call instead of the caller falling back to listing the whole repository until the file is fixed.
The backup repository cache is now also invalidated on remote update/delete (previously only handled for backup repositories proper).
No behavior change for directly-attached repositories — this phase only extends the existing cache to proxy-attached ones.

XO-2852
2026-09-29 08:58:43 +02:00
MathieuRA
2f846bf07e fix(xo-server): interruptOnDisconnect does't update server error anymore 2026-09-28 19:53:59 +02:00
MathieuRA
ae3e04fb3d fix(xo-server): server status connected only if objects are fetched 2026-09-28 19:53:59 +02:00
MathieuRA
986c6b8687 fix(rest-api): fix SSE event order 2026-09-28 19:53:59 +02:00
Thomas Moraine
386e2afe12 docs(advanced.md): remove outdated 'coming soon' mention (#10378) 2026-09-28 18:30:41 +02:00
Elise-F
d7a75b943b feat: technical release (#10467) 2026-09-28 10:43:56 +02:00
Mathieu
aea3717f81 feat(xo-server,rest-api,xo-web,types/rpu): keep RPU recovery to XCP-ng and XenServer 8.4+ (#10447) 2026-09-28 09:59:27 +02:00
Pierre Brunet
5972cf5c59 fix(vhd-lib): update changelog (#10462) 2026-09-28 09:47:03 +02:00
MathieuRA
2b466d6ac8 fix(perf-alert): fix SR dead link
introduced by 779876ede9
2026-09-28 09:42:52 +02:00
MathieuRA
8ac91f9d7d fix(perf-alert): fix test plugin
introduced by 779876ede9
2026-09-28 09:42:52 +02:00
Killian
f9cafe6a0f fix(backups): actually delete interrupted replication transfers (#10436)
* fix(backups): actually delete interrupted replication transfers on next run

IncrementalXapiWriter#_prepare's "delete previous interrupted copies" cleanup
was dead code: `vm => vm.$destroy` never called the method, and it filtered
with `srUuid: undefined`, which can never match since every replica has had
REPLICATED_TO_SR_UUID set since c610951450. So a transfer interrupted mid-way
(process crash, killed job, ...) left its VM/VDI on the target SR forever,
still carrying decorateVmMetadata's DATETIME epoch sentinel.

Fix the filter to use the real srUuid and match only entries still carrying
that sentinel, then actually call $destroy(). Exclude this._targetVmRef: when
checkBaseVdis (which runs earlier) already validated the same VM as reusable
for this run, it must survive to be chained onto, not be destroyed under it.
2026-09-28 09:33:46 +02:00
Florent BEAUCHAMP
a9caea651c fix(xo-server): ignore tests on startup (#10463)
detected on previous technical release
2026-09-28 09:04:15 +02:00
vates-bot
27882fb1dc feat(web-stack/i18n): update Xen Orchestra 6 (#10396) 2026-09-25 17:03:34 +02:00
Pierre Brunet
99af2031c4 feat(backups): improve doc (#10390)
improve backup documentation for developers
2026-09-25 16:28:16 +02:00
Elise-F
22706164d6 feat: technical release (#10458) 2026-09-25 15:03:27 +02:00
Florent BEAUCHAMP
dc57333f58 feat(v2v): remove vddk dependency (#10421)
Co-authored-by: Mathieu <mathieu.piton@vates.tech>
2026-09-25 10:38:26 +02:00
spacotte-vates
3fea79d899 feat(xo-web): add support for IS_XOA_BUILD var (#10429) 2026-09-25 10:34:55 +02:00
Florent BEAUCHAMP
f4fa020d2e feat(backups): make live mount restore works with xo-proxy (#10431) 2026-09-25 10:15:17 +02:00
Florent BEAUCHAMP
b5f119f1d4 feat(nbd-client): implement a nbd-client for stdin/stdout instead of a tcp connection (#10381) 2026-09-25 09:57:53 +02:00
Florent BEAUCHAMP
d1029ffb24 docs(live-mount): add documentation of live mount (#10445)
* docs(live-mount): add documentation of live mount

Co-authored-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-09-25 09:48:11 +02:00
Mathieu
a4b17d5abb fix(rest-api): triggers updates for objects that are unmodified but computed (#10441) 2026-09-24 15:43:41 +02:00
Elise-F
b1b5097b6b feat(lite) : add action forget on host (#10315)
* update changelog file

* feedback one

* feat(lite): add forget action on host

* udpate changelog file

* fix rebase

* feedback

* rebase
2026-09-24 15:15:09 +02:00
Marie Garde
5ba393ca2b feat(xo6): fix imports vif form (#10451) 2026-09-24 14:59:32 +02:00
Marie Garde
b3b02b1fef feat(lite): Create Vif from VM Network (#10216) 2026-09-24 14:23:18 +02:00
Killian
6b4a7e5ebd fix(jobs): updates a sequence's runner user on save (#10423)
* fix(jobs): updates a sequence's runner user on save
2026-09-24 13:14:00 +02:00
MlssFrncJrg
9e38c0bac2 fix(xo-web): VDI migration now shows all pool SRs for admins, even on VMs bound to a Resource Set (#10422) 2026-09-24 12:14:16 +02:00
amouillard-vates
4d6a5ffa60 feat(web-core): add accent prop to MenuItem (#10348) 2026-09-24 09:01:20 +02:00
Mathieu
fc571c00d4 feat(xo-server,rest-api,xo-web/rpu): force-close an incomplete rolling pool update record (#10418)
* feat(xo-server,rest-api,xo-web/rpu): force-close an incomplete rolling pool update record

A pool whose previous rolling pool update left a recovery record refuses a
new run until that record is dealt with. Once the operator has reviewed the
pool, `pool.finalizeRollingUpdate` and
`POST /rest/v0/pools/{id}/actions/finalize_rolling_update` close it.

Without `force`, the finalization is refused (`incorrectState`, property
`unrestoredItems`) while the record says the run changed something the pool
still shows changed: HA, auto power-on, WLB, load balancer, backup
schedules, hosts left disabled, running VMs away from their home host,
pinned VMs shut down and not started again. A record that cannot be read
is refused too, with an unknown list.

With `force`, the record is deleted, the abandoned items are written to the
task and the trace, nothing is restored nor changed in the pool.

To tell what the run changed from what the operator did, the run now
persists, before each change, the settings it touches (strict write) and
whether each host was enabled before the run.

XO 5 shows a "Finalize" action on the incomplete update banner, which is
now also shown for a run that succeeded but whose record could not be
removed. Forcing asks for a second confirmation listing the items.
2026-09-23 16:53:43 +02:00
amouillard-vates
974c2cfee9 feat(xo6): sort networks by name on hosts > networks table (#10367) 2026-09-23 15:51:42 +02:00
Thierry Goettelmann
fc6124b1b8 feat(web-core/table): add row selection, checkbox column and controls bar (#10435) 2026-09-23 15:30:29 +02:00
Thomas Moraine
97d338710d doc(xo): remove SECURITY.md to apply global Vates security policy (#10405)
This commit removes the local `SECURITY.md` file from the Xen Orchestra repository.

The new global Vates security policy is now in place, so the local file
is no longer needed and should be replaced by the central policy:
https://github.com/vatesfr/.github/blob/main/SECURITY.md

This ensures consistent security reporting guidelines across Vates projects.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-09-23 13:27:17 +02:00
Grandalf
e957c2d821 test(backups): cover VM backups cache invalidation and local mutations (#10261)
test(xo-server/backups): cover VM backups cache invalidation and local mutations
2026-09-23 11:22:36 +02:00
Elise-F
a1dba1ed4e feat(lite): add start shutdown action host (#10314)
* update changelog file

* feedback one

* update changelog file

* feat(lite): add start shutdown action host

* update changelog file

* feeback 1

* feedback 2

* feedback 3
2026-09-22 15:32:35 +02:00
Marie Garde
26a71c4d4b feat(xo-remote-parser): migration JS to TS and update tests (#10399) 2026-09-22 14:50:44 +02:00
ayoub-el-kajji-v
c40741ccf4 feat(rest-api): add reclaim space method per vm or backup repository (#10262) 2026-09-21 19:02:09 +02:00
ayoub-el-kajji-v
c95dec183e Fix(backup): immediate delta vm backup deletion (#10273) 2026-09-21 16:07:58 +02:00
Thierry Goettelmann
ee0b8f0468 feat(web-core): enhance collection flags configuration and add clearFlag (#10428) 2026-09-21 14:05:57 +02:00
Florent BEAUCHAMP
75d9e4afda feat(xo-web): implement read only live mount as restore target (#10345)
following the backend PR this PR implement the xo5 ui to use live mount for restoring a VM .

    the popup is enlarged to ensure there is enough space
    it fixes a ui feature where removing a VDI make it unrestorable without closing and reopening 
    A disk row is complete when its action has the destination it needs: Live mount → a host, Restore → its own SR or the main SR, Do not restore → nothing.
    An incomplete row shows a red message under its selector, and since the rows sit in a collapsed panel, a red "Some disks have no destination yet" line is also shown outside it.
    The restore SR selector's placeholder now reads "Use main SR" once a main SR is set, so an empty field reads as a deliberate fallback rather than an oversight.
    For live moiunt the picker is prefilled with the host that can use the SR
2026-09-21 11:07:56 +02:00
Sylvère
eb7471a248 chore(web-core,web,lite): update FontAwesome, clean unused icon packages imports (#10365) 2026-09-21 09:52:34 +02:00
Grandalf
cae1b72571 feat(xo-server/backups): list the backups from a journal-replayed cache (#10257)
[XO-2850]
2026-09-21 09:36:25 +02:00
Olivier Lambert
f56150c25b Revert "feat(browser-media): prototype local ISO streaming in XO 5 and XO 6"
This reverts commit 5d1d0eba2f.
2026-09-18 18:40:28 +02:00
Olivier Lambert
5d1d0eba2f feat(browser-media): prototype local ISO streaming in XO 5 and XO 6 2026-09-18 18:37:16 +02:00
Mathieu
fe69a029e1 fix(openmetrics): don't block the scrape on stale collector caches (#10409)
Introduced by 2b7b6ba
See XO-2984
2026-09-18 17:29:46 +02:00
Patrice Ferlet
12a32dce22 fix(@xen-orchestra/mcp): use undici fetch to fix Node 26 dispatcher crash (#10413)
* fix(@xen-orchestra/mcp): use undici fetch to fix Node 26 dispatcher crash

Node's global fetch is backed by Node's bundled undici and rejects a
dispatcher from the npm undici dependency with UND_ERR_INVALID_ARG on
some Node versions (e.g. Node 26), surfacing as "fetch failed". The MCP
passed an EnvHttpProxyAgent dispatcher to the global fetch, so it crashed
at startup.

Route every request through undici's own fetch (proxyFetch) so the
dispatcher and the fetch come from the same undici copy. Add a FetchFn
injection point so unit tests can mock the fetch instead of globalThis.fetch.

Fixes #10411
2026-09-18 17:14:39 +02:00
Elise-F
cafea84fee feat(lite) : add action force reboot on host (#10311)
* update changelog file

* feedback one

* update changelog file

* feat(lite): add force reboot action on host

* update changelog file

* feedback 1

* feedback loader
2026-09-18 17:02:30 +02:00
Bastien Nollet
13480c3457 fix(xo-server): prevent incidental static_memory_min edit (#10285)
XO-3040
2026-09-18 11:26:32 +02:00
Bastien Nollet
4fc992da52 feat(users): add firstname, lastname and username properties (#10163) 2026-09-17 16:53:07 +02:00
Florent BEAUCHAMP
6f73bde276 feat(backups): backend of using live disk mount for restore (#10343)
* feat(backups): implement backend for live mounting disk

when restoring a VM, the vdi to SR map is extended to support a
'live-mount' mode , additionaly to the now explicit 'restore'
and 'ignore' mode

live-mount will need the host. no check is done on backend to
ensure if the VM is bootable
 
add e2e test
2026-09-17 16:30:27 +02:00
Florent BEAUCHAMP
70cbf5cda3 feat(xo-server): implement live mounting a backup archive disk (#10302) 2026-09-17 16:10:06 +02:00
Florent BEAUCHAMP
44b7db2fd9 feat(@vates/iscsi): extract iscsi package code (#10242)
implementation of an iscsi target and initiator in typescript

this will serve as a basis of 2 projects:

    connecting directly to 3rd party san
    exposing a disk from a backup archive to the host, effectively enabling direct mount to a running VM. Final target is to be able to boot completely the VM

@vates/iscsi/VALIDATION.md file is a journal of the additional test I run on my lab
@vates/iscsi/PROTOCOL.md describe the subset of the protocol implemented . For example we only support 1 LUN per target, not multiple connection , only one type of auth and keep alive
2026-09-17 15:07:15 +02:00
Mathieu
f56d241c18 feat(xo-server,rest-api,xo-web/rpu): safe start on a partially updated pool (#10394)
* feat(xo-server,rest-api,xo-web/rpu): safe start on a partially updated pool

A rolling pool update used to overwrite the recovery record left by the
previous run and to look only at the master to decide whether there was
anything to update. Both hid a pool left half updated: an interrupted run,
or updates applied by hand, could be silently completed or forgotten.

- `startRpuRecoveryRun` now refuses a new run while the pool still has a
  record, whatever its status, with an `incorrectState` error (property
  `rollingUpdateRecovery`). The RPU guard held by the caller makes the
  check-then-write safe. A run which fails before any host was handled
  drops its record instead of marking it failed: the orchestrator restores
  what it changed on its way out, and a refused precondition (a guidance to
  accept, a pinned VM to shut down) must not block the retry the operator
  just consented to.
- The update refuses a pool whose master is already up to date while
  another host is not (`incorrectState`, property `partiallyUpdatedPool`,
  `actual` listing the outdated hosts) unless the new
  `acceptCurrentStateAsBaseline` option is set. The option is exposed on
  `pool.rollingUpdate` and on `POST /rest/v0/pools/{id}/actions/rolling_update`.
- XO5 enables the rolling pool update button as soon as any host of the
  pool has missing patches, not only the master, disables it while a
  recovery record exists, and asks for confirmation before accepting the
  current state as the baseline.

Until the force-close operation ships, a leftover record can only be
removed by hand: it must land in the same release.

* feat(xo-server,rest-api,xo-web/rpu): changelog entry for #10394

* fix(xo-server/rpu): getRollingUpdateRecovery returns null on no record

* fix(xo-server/rpu): persist acceptCurrentStateAsBaseline, mark record succeeded on delete failure

* fix(xo-server/rpu): drop an empty record after the pool is restored, update LINSTOR only past the guards
2026-09-17 14:17:06 +02:00
MlssFrncJrg
ce7d94ec77 fix(xo-server): enhance CSP handling for Netdata (#10275)
Introduced by #10101
2026-09-16 17:01:09 +02:00
Mathieu
f91d64235a fix(xo-server): fix vm topology when editing incompatible cpu max number (#10398)
* fix(xo-server): fix vm topology when editing incompatible cpu number
2026-09-16 15:51:31 +02:00
ayoub-el-kajji-v
116f50821e fix(backups): change prefix name for health checks (#10361)
[XO-2797] This PR is for changing the displayed name during backups health check from [Importing...] to [Health Check...]
Introduced by 03865bc
2026-09-16 15:28:24 +02:00
Enée DI IORIO
43f63ee784 feat(web-core): update UiLegend component to v6 (#9509) 2026-09-16 14:58:12 +02:00
Grandalf
11ec9fd5da fix(backups): don't destroy the job's own rolling snapshots (#10395)
listReplicatedVms() stopped excluding snapshots in c1e9b83 (#9524) so that new-style replication, where each transfer is a snapshot of the target VM, would be covered. But a backup snapshot of the source VM matches the very same filter: it carries xo:backup:schedule and xo:backup:vm, and setVmOtherConfig passes srUuid = null, which removes xo:backup:sr and therefore matches the srUuid = undefined argument of the "delete previous interrupted copies" sweep in FullXapiWriter._run.

A full backup job replicating to an SR of the source pool thus destroyed both the rolling snapshot retained from the previous run and the one it had just taken, silently: the sweep is fire-and-forget under ignoreErrors, and XAPI holds the destroy of the snapshot being exported until the transfer ends instead of refusing it.

A replication snapshot is a snapshot of a replica, so a snapshot of the VM designated by xo:backup:vm can never be one.
2026-09-16 14:16:26 +02:00
spacotte-vates
ea2acac77b fix(backups): don't destroy the delta base after failed transfer (#10369)
XO-2994

Customer reported that after a failed replication transfer, the next run was a full and a VM was left orphaned on the target.

This is due to _removeUnusedSnapshots() protecting the last snapshot irrelevant of if it was successfully transferred and thus, in the case of a failed transfer, breaking the delta chain
2026-09-16 10:53:02 +02:00
Pierre Brunet
14b7115cf1 fix(openmetrics): share metrics collection on slow pools (#10401) 2026-09-16 10:19:42 +02:00
MlssFrncJrg
d0645598b9 feat(xo-server/proxy): check proxy licenses from XOA instead of blocking backups on it (#10280)
XO-2768
2026-09-15 20:42:18 +02:00
Thomas Moraine
83b2fd9056 docs(releases.md): document security support (#10379)
Document which Xen Orchestra versions receive security fixes
in the actual XO doc, not just in SECURITY.md.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-09-15 17:13:13 +02:00
1932 changed files with 136411 additions and 13989 deletions

View File

@@ -10,6 +10,8 @@ on:
jobs:
changed-packages:
# the release PR bumps every listed package and empties the list
if: github.head_ref != 'release'
runs-on: ubuntu-latest
steps:
# https://github.com/actions/checkout

View File

@@ -10,7 +10,7 @@ jobs:
- name: Install packages
run: |
sudo apt-get update
sudo apt-get install -y curl qemu-utils python3-vmdkstream git libxml2-utils libfuse2 nbdkit
sudo apt-get install -y curl qemu-utils python3-vmdkstream git libxml2-utils libfuse2 nbdkit open-iscsi
# https://turbo.build/repo/docs/guides/ci-vendors/github-actions#caching-with-github-actionscache
- name: Cache Turbo

73
.github/workflows/vectura.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: Vectura
# only when vectura changes: the rest of the repository does not build it
#
# pull requests rather than every push: GitHub filters a push on the diff with
# the previous push, which after a rebase on `master` (or a merge of it)
# contains the vectura changes of `master`, whereas it filters a pull request
# on the diff with the merge base, i.e. the changes of the branch only
on:
pull_request:
paths:
- '@xen-orchestra/vmware-explorer/vectura/**'
- .github/workflows/vectura.yml
push:
branches:
- master
paths:
- '@xen-orchestra/vmware-explorer/vectura/**'
- .github/workflows/vectura.yml
defaults:
run:
working-directory: '@xen-orchestra/vmware-explorer/vectura'
jobs:
checks:
runs-on: ubuntu-latest
steps:
# https://github.com/actions/checkout
- uses: actions/checkout@v4
# rustup, on the runner image, installs the toolchain rust-toolchain.toml
# names on the first cargo command
- run: cargo fmt --all --check
- run: cargo clippy --all-targets --locked -- -D warnings
- run: cargo test --locked
# https://github.com/taiki-e/install-action
- uses: taiki-e/install-action@v2
with:
tool: cargo-deny@0.20.2,cargo-llvm-cov@0.8.5
- run: cargo deny --locked check
- run: cargo llvm-cov --locked --json --summary-only --output-path target/coverage.json
- name: Hold the coverage floor
run: cargo test --locked --test coverage -- --ignored
# builds the binary as release.sh does, checks it, then checks the one in git,
# which xo-server runs, is that build
binary:
runs-on: ubuntu-latest
steps:
# https://github.com/actions/checkout
- uses: actions/checkout@v4
# https://github.com/mlugg/setup-zig
- uses: mlugg/setup-zig@v2
with:
version: 0.16.0
# https://github.com/taiki-e/install-action
- uses: taiki-e/install-action@v2
with:
tool: cargo-zigbuild@0.23.4
- run: scripts/build.sh "$RUNNER_TEMP/build"
- run: scripts/check-binary.sh "$RUNNER_TEMP/build/vectura"
- name: The binary in git is the build of these sources
run: |
cmp "$RUNNER_TEMP/build/vectura" vectura || {
echo '::error::vectura is not the build of these sources: run scripts/build.sh . in vectura/ and commit the binary'
exit 1
}

View File

@@ -24,7 +24,7 @@
},
"dependencies": {
"@vates/multi-key-map": "^0.2.0",
"@xen-orchestra/async-map": "^0.1.4",
"@xen-orchestra/async-map": "^0.1.5",
"@xen-orchestra/log": "^0.7.2",
"ensure-array": "^1.0.0"
},

View File

@@ -19,12 +19,12 @@
},
"main": "./index.mjs",
"dependencies": {
"@xen-orchestra/fs": "^4.9.3",
"@xen-orchestra/fs": "^4.10.0",
"@xen-orchestra/log": "^0.7.2",
"fuse-native": "^2.2.6",
"lru-cache": "^7.14.0",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.16.1"
"vhd-lib": "^4.17.0"
},
"bin": {
"xo-fuse-vhd": "./cli.mjs"

261
@vates/iscsi/.USAGE.md Normal file
View File

@@ -0,0 +1,261 @@
A small, dependency-light iSCSI **target** (server) written in TypeScript. It
exposes exactly one read/write LUN backed by a pluggable byte-range
`BlockDevice`, and interoperates with the standard Linux **open-iscsi**
initiator (the same one XCP-ng/XAPI uses to attach an `lvmoiscsi` SR).
### Scope
The target drives login negotiation onto a single, fixed code path:
- single initiator, **`MaxConnections=1`**, **`ErrorRecoveryLevel=0`**
- no header/data digests (`HeaderDigest=None`, `DataDigest=None`)
- all writes are R2T-solicited (`InitialR2T=Yes`, `ImmediateData=No`)
- one LUN (LUN 0)
It implements SendTargets discovery, login (`AuthMethod=None` or one-way
**CHAP** — see [Authentication](#authentication-chap)), the ~10 SCSI
commands a Linux initiator issues to attach and use a block device
(`INQUIRY` + VPD `0x00`/`0x80`/`0x83`/`0xb0`, `REPORT LUNS`, `READ CAPACITY 10/16`,
`READ`/`WRITE 10/16`, `TEST UNIT READY`, `REQUEST SENSE`, `MODE SENSE`,
`SYNCHRONIZE CACHE`), the full R2T / Data-Out write path, NOP keepalive replies,
and Logout. Commands may be outstanding concurrently (Data-Out PDUs are routed
by Initiator Task Tag), so it does not stall under real guest I/O.
The package also ships a userspace **initiator** (`IscsiInitiator`) and an
`IscsiDisk` adapter that exposes a remote LUN as an `@xen-orchestra/disk-transform`
`RandomAccessDisk` — see [Initiator](#initiator).
Not implemented (yet): mutual (two-way) CHAP, Task Management (abort/reset),
target-initiated NOP keepalives, multiple connections/LUNs.
What a peer can make the target do is bounded:
- connections that have not logged in yet are limited in time (`loginTimeoutMs`)
and number (`maxPendingConnections`), and `allowedInitiatorAddresses` (the
source addresses of the expected initiators) closes the other ones as soon as
they are accepted, reporting them to `onConnectionRefused`
- a PDU data segment is at most 8 KiB during login, then the declared
`MaxRecvDataSegmentLength` (256 KiB), checked before it is read
- a single `READ`/`WRITE` is at most `maxTransferLength` (8 MiB by default),
advertised in the block limits VPD page so that initiators split their I/O
- a `BlockDevice` with `readOnly: true` is reported write-protected, and `WRITE`
is refused with `DATA PROTECT` before any data is solicited
### CLI
The package ships a `vates-iscsi` binary that serves a file as a LUN:
```sh
> vates-iscsi /srv/lun.img
> vates-iscsi /srv/lun.img --serial MYSERIAL01 --port 3260
> vates-iscsi /srv/new.img --size 10G # create/grow the backing file first
```
```
Usage: vates-iscsi <file> [options]
Options:
--serial <serial> LUN serial number (default: derived from the IQN).
--size <bytes> Create/grow the backing file (suffixes K, M, G, T).
--iqn <iqn> Target IQN (default: iqn.2024-01.tech.vates:<file>).
--host <host> Listen address (default: 0.0.0.0).
--port <port> Listen port (default: 3260).
--block-size <bytes> Logical block size (default: 512).
-h, --help Show this help.
```
### Library
```js
import { IscsiTarget, FileBlockDevice } from '@vates/iscsi'
// The backing file must already exist and be sized to the LUN capacity,
// e.g. `fs.truncate('/srv/lun.img', 10 * 1024 ** 3)`.
const target = new IscsiTarget({
iqn: 'iqn.2024-01.tech.vates:lun0',
host: '0.0.0.0',
port: 3260,
lun: new FileBlockDevice({ path: '/srv/lun.img', blockSize: 512 }),
})
await target.listen()
// ... target is now discoverable and attachable by open-iscsi ...
await target.close()
```
#### Adding a target backend
A "backend" is whatever stores the LUN's bytes. The protocol layer translates
SCSI `READ`/`WRITE` CDBs (LBA + block count) into byte offsets/lengths against a
single small interface, so a backend only has to provide flat random access — it
never sees a PDU. `FileBlockDevice` (a sparse file) is the built-in one; implement
`BlockDevice` to expose anything else (a raw device, an object store, an
`@xen-orchestra/disk-transform` `RandomAccessDisk`, …):
```ts
interface BlockDevice {
open?(): Promise<void> // optional, awaited once before serving I/O
getSize(): number // total capacity in bytes; MUST be a multiple of getBlockSize()
getBlockSize(): number // logical block size, typically 512
read(offset: number, length: number): Promise<Buffer> // exactly `length` bytes at byte `offset`
write(offset: number, data: Buffer): Promise<void> // write `data.length` bytes at byte `offset`
flush(): Promise<void> // SYNCHRONIZE CACHE — persist any buffering
close(): Promise<void> // release resources
}
```
Contract to respect:
- **`getSize()` must be a multiple of `getBlockSize()`** and stable for the life of
the device. `open()` is the place to compute it (e.g. `stat` the file).
- **`read` must return exactly `length` bytes** — never short. Zero-fill a sparse
or past-EOF tail rather than returning fewer bytes; the SCSI layer already
bounds `offset + length` to the capacity.
- `offset` and `length` are always block-aligned (multiples of `getBlockSize()`).
- `read`/`write` may be **called concurrently** (commands are interleaved), so the
backend must be safe under overlapping calls; honour back-pressure and don't
buffer unboundedly.
- Throwing from `read`/`write`/`flush` tears the connection down; fail fast and
attach context to the error.
Minimal example — expose a read-only `RandomAccessDisk` (VHD / qcow2 / raw / a
remote NBD or iSCSI LUN) as a target LUN by mapping byte reads onto block reads:
```js
import { IscsiTarget } from '@vates/iscsi'
class RandomAccessDiskLun {
// `disk` is an initialized @xen-orchestra/disk-transform RandomAccessDisk.
constructor(disk) {
this.disk = disk
this.blockSize = disk.getBlockSize()
}
getSize() {
return this.disk.getVirtualSize()
}
getBlockSize() {
return this.blockSize
}
async read(offset, length) {
const out = Buffer.alloc(length) // pre-zeroed: sparse/holes read as zeros
const first = Math.floor(offset / this.blockSize)
const last = Math.floor((offset + length - 1) / this.blockSize)
for (let index = first; index <= last; index++) {
if (!this.disk.hasBlock(index)) {
continue // unallocated: leave zeros
}
const { data } = await this.disk.readBlock(index)
const blockStart = index * this.blockSize
const from = Math.max(0, offset - blockStart)
const to = Math.min(this.blockSize, offset + length - blockStart)
data.copy(out, blockStart + from - offset, from, to)
}
return out
}
// reported write-protected: WRITE is refused before reaching write()
readOnly = true
write() {
throw new Error('read-only LUN')
}
async flush() {}
async close() {
await this.disk.close()
}
}
const target = new IscsiTarget({ iqn: 'iqn.2024-01.tech.vates:disk0', lun: new RandomAccessDiskLun(disk) })
await target.listen()
```
For a writable backend, implement `write(offset, data)` symmetrically and make
`flush()` durable (e.g. `fsync`), as `FileBlockDevice` does.
### Initiator
`IscsiInitiator` is a minimal userspace iSCSI **client**: it connects to one
target portal, logs in, and reads the LUN. `IscsiDisk` wraps it as an
`@xen-orchestra/disk-transform` `RandomAccessDisk`, so a remote LUN drops straight
into the backup/transform pipeline (the iSCSI counterpart of `@vates/nbd-client`'s
`NbdDisk`). It is **read-only**, single-connection, digests off.
```js
import { IscsiInitiator, IscsiDisk } from '@vates/iscsi'
// Low-level byte reads:
const initiator = new IscsiInitiator({
host: '10.0.0.10',
port: 3260,
targetIqn: 'iqn.2024-01.tech.vates:lun0',
chap: { user: 'alice', secret: 's3cr3t' }, // optional, see Authentication
})
await initiator.connect()
console.log(initiator.getSize(), initiator.getBlockSize())
const bytes = await initiator.read(/* offset */ 0, /* length */ 1024 * 1024)
await initiator.close()
// …or as a RandomAccessDisk (blockSize must be a multiple of the LUN block size):
const disk = new IscsiDisk(
{ host: '10.0.0.10', targetIqn: 'iqn.2024-01.tech.vates:lun0' },
{ blockSize: 2 * 1024 * 1024 }
)
await disk.init()
// disk.getVirtualSize(), disk.getBlockIndexes(), await disk.readBlock(i), …
await disk.close()
```
First-cut `IscsiDisk` treats the LUN as fully allocated (every block reported as
present, like `RawDisk`); a changed-block map (e.g. a Pure "Volume Difference")
can narrow it later.
### Authentication (CHAP)
The target and initiator support **one-way CHAP** (MD5) in opposite roles; mutual
(two-way) CHAP is not yet implemented.
**Target as authenticator.** Pass `chap` to require each _data_ session to prove a
credential; the target challenges the initiator and rejects the login (Login
Response status-class _Initiator Error_ / _authentication failure_) on a mismatch:
```js
const target = new IscsiTarget({
iqn: 'iqn.2024-01.tech.vates:lun0',
lun,
chap: { user: 'alice', secret: 's3cr3t' },
})
```
This interoperates with the Linux **open-iscsi** initiator (the one XCP-ng uses);
configure its node record's incoming credential:
```sh
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.authmethod -v CHAP
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.username -v alice
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.password -v s3cr3t
```
CHAP is enforced only on **Normal (data) sessions**; **Discovery** sessions (which
can only run SendTargets and never reach the LUN) stay unauthenticated, so plain
`iscsiadm -m discovery` keeps working. open-iscsi requires the secret to be 12–16
characters.
**Initiator as responder.** Pass `chap` to `IscsiInitiator`/`IscsiDisk` to answer a
target's challenge (e.g. a Pure array acting as authenticator). If the target
_requires_ mutual CHAP the login is cleanly rejected (we never challenge back).
### Security
iSCSI has no data-path encryption of its own. CHAP authenticates the login only;
with no digests, **CDBs and data remain cleartext**. Anyone who can reach an
unauthenticated port can read/write the LUN, and a CHAP-protected one is still
exposed to eavesdropping and tampering on the wire. Expose targets only on a
trusted, isolated storage network, and use **IPsec** (the RFC-specified mechanism)
or equivalent if confidentiality is required.
### Testing
- `yarn test` — fast unit tests (PDU framing, SCSI encoders/decoders, login
negotiation, sequence-number arithmetic).
- `yarn test-integration` — a self-contained loopback test (an in-process
minimal initiator drives discovery, identity, write/read-back, and command
interleaving), plus an open-iscsi interop test that **requires root and
`iscsiadm`** and skips gracefully otherwise.

1
@vates/iscsi/.npmignore Symbolic link
View File

@@ -0,0 +1 @@
../../scripts/npmignore

124
@vates/iscsi/PROTOCOL.md Normal file
View File

@@ -0,0 +1,124 @@
# Protocol coverage
`@vates/iscsi` implements a deliberately narrow subset of iSCSI (RFC 7143, formerly RFC 3720) and the SCSI
command set it carries (SPC-3 primary commands, SBC-3 block commands) — enough to serve or consume exactly one
read/write LUN over a single connection, not a general-purpose iSCSI stack. This is the detailed companion to
the README's "Scope" section, verified directly against the source (`constants.mts`, `login.mts`,
`connection.mts`, `types.mts`, `initiator.mts`, `chap.mts`) rather than inferred.
The package has two independent roles with very different maturity:
- **Target** (`IscsiTarget`) — the primary, production-used role (backs `@xen-orchestra/mixins/live-mount`).
Handles real-world Linux/XCP-ng initiators robustly: concurrent commands, real write ordering, keepalives,
connection recovery.
- **Initiator** (`IscsiInitiator`) — a minimal, read-only client used to read from a third-party array as a
disk source. Built against `IscsiTarget` and standard-tooling targets (`tgt`); interop testing this session
found it does **not** complete login against a LIO kernel target (see below) — a real, previously-unknown
gap, not yet fixed.
## Session and connection
| Aspect | Target | Initiator |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- |
| Connections per session | `MaxConnections=1`, enforced | opens exactly one |
| Session reinstatement | **no** — no ISID tracking; once a new connection has established a Normal session (CHAP included), it tears down and replaces whatever connection currently exists, rather than RFC 7143's ISID-matched reinstatement (see `index.mts`'s class doc for the reasoning: safe here because the target is ephemeral, single-consumer, and CHAP-guarded). Until then it does not affect the current one, and is bounded by `loginTimeoutMs` and `maxPendingConnections` | n/a (single connection) |
| Multiple LUNs | **no** — exactly one LUN, always LUN 0 | reads are hardcoded to LUN 0 |
| `ErrorRecoveryLevel` | `0` only | requests `0` |
| Header/data digests | `HeaderDigest=None`, `DataDigest=None` — no CRC32C on the wire | same |
| Markers (`IFMarker`/`OFMarker`) | not offered (RFC-deprecated) | not offered |
## Login / text negotiation
**Target** (`login.mts`) reactively answers whatever the initiator proposes, fixed to:
`HeaderDigest=None`, `DataDigest=None`, `MaxConnections=1`, `InitialR2T=Yes`, `ImmediateData=No`,
`ErrorRecoveryLevel=0`, `DataPDUInOrder=Yes`, `DataSequenceInOrder=Yes`, `DefaultTime2Wait=2`,
`DefaultTime2Retain=0`, `MaxOutstandingR2T=1` — `MaxBurstLength`/`FirstBurstLength` are the only two keys
actually negotiated down from the initiator's offer (capped at an internal maximum). Both `Normal` and
`Discovery` session types are supported (`SessionType`), with CHAP skipped for `Discovery` per RFC — text-mode
negotiation beyond that is limited to `SendTargets` discovery (`#handleText`), not general text negotiation.
**Initiator** (`initiator.mts#operationalLogin`) sends exactly **one** operational-stage login PDU proposing
its own fixed key set (`HeaderDigest=None`, `DataDigest=None`, `InitialR2T=Yes`, `ImmediateData=No`,
`ErrorRecoveryLevel=0`, `MaxRecvDataSegmentLength`) with the transit bit (`T=1`) set immediately, and only
checks whether the reply also has `T=1` — there is no second round to answer a target's own counter-proposed
keys if it declines transit on the first exchange.
- Works against `tgt` and against this package's own `IscsiTarget` — both transit on the first round.
- **Does not complete against a LIO kernel target** (`targetcli-fb`): LIO counter-proposes its own operational
keys (`MaxBurstLength`, `DefaultTime2Wait`, `DefaultTime2Retain`, `MaxOutstandingR2T`) in the same response
and withholds transit until they're answered — confirmed directly via `dmesg`:
```
No response for proposed key "MaxBurstLength".
iSCSI Login negotiation failed.
```
This is a real, previously-unexercised limitation of the initiator's login state machine (single
operational round-trip only), not a bug on LIO's side. Worth weighing given the initiator's real intended
use — reading from unknown-strictness third-party array software, not `tgt`/this package's own target.
## Authentication (CHAP)
- One-way only (RFC 1994, algorithm MD5 — the only one RFC 7143 defines, so this isn't a partial
implementation of a wider set).
- **Target as authenticator**: when `chap` is configured, every login must present the right `CHAP_N`/`CHAP_R`
or the login is rejected (`INITIATOR_ERROR`/`AUTHENTICATION_FAILURE`). Interop-tested against the real
`open-iscsi` kernel initiator (both correct-secret and wrong-secret paths).
- **Initiator as responder**: when `chap` is configured, answers a target's challenge with the same MD5
scheme. Never issues its own challenge.
- **Not supported**: mutual (two-way) CHAP in either role — a target that _requires_ mutual auth will reject
the initiator's login, surfaced as a normal login failure rather than attempted.
## SCSI commands
Decoded explicitly (`types.mts#decodeCdb`); anything else maps to `unsupported` → `CHECK CONDITION` /
`ILLEGAL_REQUEST` / `INVALID COMMAND OPERATION CODE`:
| Command | Variants |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `INQUIRY` | standard + EVPD `0x00`/`0x80`/`0x83` (vendor/product/revision/serial from `identity`) and `0xb0` (block limits: `maxTransferLength`) |
| `REPORT LUNS` | |
| `READ CAPACITY` | `(10)` and `(16)` |
| `READ` | `(10)` and `(16)`; longer than `maxTransferLength` → `INVALID FIELD IN CDB` |
| `WRITE` | `(10)` and `(16)`; longer than `maxTransferLength` → `INVALID FIELD IN CDB`; on a `readOnly` LUN → `DATA PROTECT` / `WRITE PROTECTED`, without soliciting data |
| `TEST UNIT READY` | |
| `REQUEST SENSE` | |
| `MODE SENSE` | `(6)` and `(10)`; `WP` set on a `readOnly` LUN |
| `SYNCHRONIZE CACHE` | `(10)` and `(16)` — flushes the backing `BlockDevice` |
**Not implemented**: persistent reservations, VAAI/thin-provisioning primitives (`UNMAP`, `WRITE SAME`, `COMPARE
AND WRITE`, ...), any vendor-specific commands — anything beyond the ~10 commands a Linux initiator actually
issues to attach and use a plain block device.
## Data transfer
- **Read**: `Data-In`, concurrent commands dispatched up to `readConcurrency` (default 16) regardless of the
advertised command window — bounds how many `lun.read()` calls run at once against the real backend.
Residual under/overflow reported against the CDB's requested transfer length.
- **Write**: always solicited (`InitialR2T=Yes`, `ImmediateData=No`) — every write goes through `R2T` →
`Data-Out`, one outstanding R2T at a time (`MaxOutstandingR2T=1`). `Data-Out` PDUs are routed by Initiator
Task Tag, so writes/reads for different commands can interleave; within one write, out-of-order or
overlapping `Data-Out` segments are rejected outright (`writePath.mts`) rather than silently accepted, to
avoid leaking uninitialized staging-buffer memory to the LUN (a real bug this was hardened against — see
`VALIDATION.md`).
- **Not implemented**: unsolicited/immediate data, `MaxOutstandingR2T > 1`.
## Task management, error recovery, keepalives
- **SCSI Task Management** (`ABORT TASK`, `LUN RESET`, etc.) — not implemented; any `SCSI Task Mgmt Request`
is rejected (`COMMAND_NOT_SUPPORTED`).
- **SNACK** (retransmission requests, part of `ErrorRecoveryLevel > 0`) — not implemented; rejected the same
way. Consistent with `ErrorRecoveryLevel=0` — nothing should ever need to SNACK.
- **NOP / keepalives**:
- Target replies to an initiator-sent `NOP-Out` ping, but does **not** proactively send its own periodic
`NOP-In` probes (no `nop_interval` equivalent) — it relies entirely on the initiator (or TCP-level
connection loss) to notice a dead peer.
- Initiator answers a target-initiated `NOP-In` ping with `NOP-Out` and keeps its read loop draining for the
life of the connection specifically so it never misses one (this was a real, fixed bug — see
`VALIDATION.md`, item 5).
## Summary: not implemented
Mutual (two-way) CHAP · SCSI Task Management (abort/reset) · target-initiated NOP-In keepalives · multiple
connections per session · multiple LUNs · session reinstatement (ISID) · header/data digests · unsolicited
data / `MaxOutstandingR2T>1` · SNACK/error recovery beyond level 0 · persistent reservations and VAAI-style
primitives · general text-mode negotiation beyond `SendTargets` · initiator-side multi-round operational login
negotiation (works against this package's own target and `tgt`; does not against LIO — see above).

294
@vates/iscsi/README.md Normal file
View File

@@ -0,0 +1,294 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/iscsi
[![Package Version](https://badgen.net/npm/v/@vates/iscsi)](https://npmjs.org/package/@vates/iscsi) ![License](https://badgen.net/npm/license/@vates/iscsi) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/iscsi)](https://bundlephobia.com/result?p=@vates/iscsi) [![Node compatibility](https://badgen.net/npm/node/@vates/iscsi)](https://npmjs.org/package/@vates/iscsi)
> Minimal iSCSI target and initiator: expose or read a single LUN, with one-way CHAP
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/iscsi):
```sh
npm install --save @vates/iscsi
```
## Usage
A small, dependency-light iSCSI **target** (server) written in TypeScript. It
exposes exactly one read/write LUN backed by a pluggable byte-range
`BlockDevice`, and interoperates with the standard Linux **open-iscsi**
initiator (the same one XCP-ng/XAPI uses to attach an `lvmoiscsi` SR).
### Scope
The target drives login negotiation onto a single, fixed code path:
- single initiator, **`MaxConnections=1`**, **`ErrorRecoveryLevel=0`**
- no header/data digests (`HeaderDigest=None`, `DataDigest=None`)
- all writes are R2T-solicited (`InitialR2T=Yes`, `ImmediateData=No`)
- one LUN (LUN 0)
It implements SendTargets discovery, login (`AuthMethod=None` or one-way
**CHAP** — see [Authentication](#authentication-chap)), the ~10 SCSI
commands a Linux initiator issues to attach and use a block device
(`INQUIRY` + VPD `0x00`/`0x80`/`0x83`/`0xb0`, `REPORT LUNS`, `READ CAPACITY 10/16`,
`READ`/`WRITE 10/16`, `TEST UNIT READY`, `REQUEST SENSE`, `MODE SENSE`,
`SYNCHRONIZE CACHE`), the full R2T / Data-Out write path, NOP keepalive replies,
and Logout. Commands may be outstanding concurrently (Data-Out PDUs are routed
by Initiator Task Tag), so it does not stall under real guest I/O.
The package also ships a userspace **initiator** (`IscsiInitiator`) and an
`IscsiDisk` adapter that exposes a remote LUN as an `@xen-orchestra/disk-transform`
`RandomAccessDisk` — see [Initiator](#initiator).
Not implemented (yet): mutual (two-way) CHAP, Task Management (abort/reset),
target-initiated NOP keepalives, multiple connections/LUNs.
What a peer can make the target do is bounded:
- connections that have not logged in yet are limited in time (`loginTimeoutMs`)
and number (`maxPendingConnections`), and `allowedInitiatorAddresses` (the
source addresses of the expected initiators) closes the other ones as soon as
they are accepted, reporting them to `onConnectionRefused`
- a PDU data segment is at most 8 KiB during login, then the declared
`MaxRecvDataSegmentLength` (256 KiB), checked before it is read
- a single `READ`/`WRITE` is at most `maxTransferLength` (8 MiB by default),
advertised in the block limits VPD page so that initiators split their I/O
- a `BlockDevice` with `readOnly: true` is reported write-protected, and `WRITE`
is refused with `DATA PROTECT` before any data is solicited
### CLI
The package ships a `vates-iscsi` binary that serves a file as a LUN:
```sh
> vates-iscsi /srv/lun.img
> vates-iscsi /srv/lun.img --serial MYSERIAL01 --port 3260
> vates-iscsi /srv/new.img --size 10G # create/grow the backing file first
```
```
Usage: vates-iscsi <file> [options]
Options:
--serial <serial> LUN serial number (default: derived from the IQN).
--size <bytes> Create/grow the backing file (suffixes K, M, G, T).
--iqn <iqn> Target IQN (default: iqn.2024-01.tech.vates:<file>).
--host <host> Listen address (default: 0.0.0.0).
--port <port> Listen port (default: 3260).
--block-size <bytes> Logical block size (default: 512).
-h, --help Show this help.
```
### Library
```js
import { IscsiTarget, FileBlockDevice } from '@vates/iscsi'
// The backing file must already exist and be sized to the LUN capacity,
// e.g. `fs.truncate('/srv/lun.img', 10 * 1024 ** 3)`.
const target = new IscsiTarget({
iqn: 'iqn.2024-01.tech.vates:lun0',
host: '0.0.0.0',
port: 3260,
lun: new FileBlockDevice({ path: '/srv/lun.img', blockSize: 512 }),
})
await target.listen()
// ... target is now discoverable and attachable by open-iscsi ...
await target.close()
```
#### Adding a target backend
A "backend" is whatever stores the LUN's bytes. The protocol layer translates
SCSI `READ`/`WRITE` CDBs (LBA + block count) into byte offsets/lengths against a
single small interface, so a backend only has to provide flat random access — it
never sees a PDU. `FileBlockDevice` (a sparse file) is the built-in one; implement
`BlockDevice` to expose anything else (a raw device, an object store, an
`@xen-orchestra/disk-transform` `RandomAccessDisk`, …):
```ts
interface BlockDevice {
open?(): Promise<void> // optional, awaited once before serving I/O
getSize(): number // total capacity in bytes; MUST be a multiple of getBlockSize()
getBlockSize(): number // logical block size, typically 512
read(offset: number, length: number): Promise<Buffer> // exactly `length` bytes at byte `offset`
write(offset: number, data: Buffer): Promise<void> // write `data.length` bytes at byte `offset`
flush(): Promise<void> // SYNCHRONIZE CACHE — persist any buffering
close(): Promise<void> // release resources
}
```
Contract to respect:
- **`getSize()` must be a multiple of `getBlockSize()`** and stable for the life of
the device. `open()` is the place to compute it (e.g. `stat` the file).
- **`read` must return exactly `length` bytes** — never short. Zero-fill a sparse
or past-EOF tail rather than returning fewer bytes; the SCSI layer already
bounds `offset + length` to the capacity.
- `offset` and `length` are always block-aligned (multiples of `getBlockSize()`).
- `read`/`write` may be **called concurrently** (commands are interleaved), so the
backend must be safe under overlapping calls; honour back-pressure and don't
buffer unboundedly.
- Throwing from `read`/`write`/`flush` tears the connection down; fail fast and
attach context to the error.
Minimal example — expose a read-only `RandomAccessDisk` (VHD / qcow2 / raw / a
remote NBD or iSCSI LUN) as a target LUN by mapping byte reads onto block reads:
```js
import { IscsiTarget } from '@vates/iscsi'
class RandomAccessDiskLun {
// `disk` is an initialized @xen-orchestra/disk-transform RandomAccessDisk.
constructor(disk) {
this.disk = disk
this.blockSize = disk.getBlockSize()
}
getSize() {
return this.disk.getVirtualSize()
}
getBlockSize() {
return this.blockSize
}
async read(offset, length) {
const out = Buffer.alloc(length) // pre-zeroed: sparse/holes read as zeros
const first = Math.floor(offset / this.blockSize)
const last = Math.floor((offset + length - 1) / this.blockSize)
for (let index = first; index <= last; index++) {
if (!this.disk.hasBlock(index)) {
continue // unallocated: leave zeros
}
const { data } = await this.disk.readBlock(index)
const blockStart = index * this.blockSize
const from = Math.max(0, offset - blockStart)
const to = Math.min(this.blockSize, offset + length - blockStart)
data.copy(out, blockStart + from - offset, from, to)
}
return out
}
// reported write-protected: WRITE is refused before reaching write()
readOnly = true
write() {
throw new Error('read-only LUN')
}
async flush() {}
async close() {
await this.disk.close()
}
}
const target = new IscsiTarget({ iqn: 'iqn.2024-01.tech.vates:disk0', lun: new RandomAccessDiskLun(disk) })
await target.listen()
```
For a writable backend, implement `write(offset, data)` symmetrically and make
`flush()` durable (e.g. `fsync`), as `FileBlockDevice` does.
### Initiator
`IscsiInitiator` is a minimal userspace iSCSI **client**: it connects to one
target portal, logs in, and reads the LUN. `IscsiDisk` wraps it as an
`@xen-orchestra/disk-transform` `RandomAccessDisk`, so a remote LUN drops straight
into the backup/transform pipeline (the iSCSI counterpart of `@vates/nbd-client`'s
`NbdDisk`). It is **read-only**, single-connection, digests off.
```js
import { IscsiInitiator, IscsiDisk } from '@vates/iscsi'
// Low-level byte reads:
const initiator = new IscsiInitiator({
host: '10.0.0.10',
port: 3260,
targetIqn: 'iqn.2024-01.tech.vates:lun0',
chap: { user: 'alice', secret: 's3cr3t' }, // optional, see Authentication
})
await initiator.connect()
console.log(initiator.getSize(), initiator.getBlockSize())
const bytes = await initiator.read(/* offset */ 0, /* length */ 1024 * 1024)
await initiator.close()
// …or as a RandomAccessDisk (blockSize must be a multiple of the LUN block size):
const disk = new IscsiDisk(
{ host: '10.0.0.10', targetIqn: 'iqn.2024-01.tech.vates:lun0' },
{ blockSize: 2 * 1024 * 1024 }
)
await disk.init()
// disk.getVirtualSize(), disk.getBlockIndexes(), await disk.readBlock(i), …
await disk.close()
```
First-cut `IscsiDisk` treats the LUN as fully allocated (every block reported as
present, like `RawDisk`); a changed-block map (e.g. a Pure "Volume Difference")
can narrow it later.
### Authentication (CHAP)
The target and initiator support **one-way CHAP** (MD5) in opposite roles; mutual
(two-way) CHAP is not yet implemented.
**Target as authenticator.** Pass `chap` to require each _data_ session to prove a
credential; the target challenges the initiator and rejects the login (Login
Response status-class _Initiator Error_ / _authentication failure_) on a mismatch:
```js
const target = new IscsiTarget({
iqn: 'iqn.2024-01.tech.vates:lun0',
lun,
chap: { user: 'alice', secret: 's3cr3t' },
})
```
This interoperates with the Linux **open-iscsi** initiator (the one XCP-ng uses);
configure its node record's incoming credential:
```sh
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.authmethod -v CHAP
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.username -v alice
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.password -v s3cr3t
```
CHAP is enforced only on **Normal (data) sessions**; **Discovery** sessions (which
can only run SendTargets and never reach the LUN) stay unauthenticated, so plain
`iscsiadm -m discovery` keeps working. open-iscsi requires the secret to be 12–16
characters.
**Initiator as responder.** Pass `chap` to `IscsiInitiator`/`IscsiDisk` to answer a
target's challenge (e.g. a Pure array acting as authenticator). If the target
_requires_ mutual CHAP the login is cleanly rejected (we never challenge back).
### Security
iSCSI has no data-path encryption of its own. CHAP authenticates the login only;
with no digests, **CDBs and data remain cleartext**. Anyone who can reach an
unauthenticated port can read/write the LUN, and a CHAP-protected one is still
exposed to eavesdropping and tampering on the wire. Expose targets only on a
trusted, isolated storage network, and use **IPsec** (the RFC-specified mechanism)
or equivalent if confidentiality is required.
### Testing
- `yarn test` — fast unit tests (PDU framing, SCSI encoders/decoders, login
negotiation, sequence-number arithmetic).
- `yarn test-integration` — a self-contained loopback test (an in-process
minimal initiator drives discovery, identity, write/read-back, and command
interleaving), plus an open-iscsi interop test that **requires root and
`iscsiadm`** and skips gracefully otherwise.
## Contributions
Contributions are _very_ welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
you've encountered;
- fork and create a pull request.
## License
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)

170
@vates/iscsi/VALIDATION.md Normal file
View File

@@ -0,0 +1,170 @@
# Validation notes
Extracted from a longer production-readiness pass covering both this package and the `LiveMount` mixin that
consumes it; this file keeps only what's specific to `@vates/iscsi` itself — protocol-level correctness,
concurrency, and interop with standard tooling. Local testing notes covering the LiveMount/XAPI integration,
VM boot mechanics, and deployment specifics live elsewhere and aren't reproduced here.
## Bugs found and fixed
### In this validation pass
1. **Read-path error isolation** (`scsi.mts`) — `lun.read()` failures were previously uncaught, killing the
whole connection for one bad block. Now wrapped in try/catch, reporting `CHECK_CONDITION`/`MEDIUM_ERROR` for
that command only, mirroring the write path's existing `#handleDataOut` error handling.
- Unit test: `scsi.test.mts` — "reports a failed lun.read() as CHECK CONDITION / MEDIUM ERROR, not an
uncaught throw".
- Regression test: `loopback.integ.mts`, `describe('READ error isolation', ...)` — a LUN whose `read()`
throws for one LBA still returns `CHECK_CONDITION` for that command; the connection stays alive and a
different, healthy LBA read afterward still succeeds.
2. **Connection replacement instead of refusal** (`index.mts`) — found via a real end-to-end guest-boot test
against an S3-backed backup repository: `IscsiTarget#onConnection` used to permanently _refuse_ any new
connection while an existing one was tracked (`MaxConnections=1` enforcement), with no way to tell a
genuinely stale/abandoned connection (initiator gave up and reconnected without ever closing the old TCP
socket) from a healthy one still in use. Under a slow backend this wedges the mount forever: the host's
initiator times out and retries, gets refused, retries again, forever — reproduced live as an 8+ minute
guest boot stall, `WARN refusing concurrent connection (MaxConnections=1)` repeating in the log the whole
time. Now a new connection always tears down whatever connection currently exists and takes over, rather
than being refused. Deliberately not full RFC 7143 session reinstatement (no ISID matching) — chosen as the
simpler, sufficient fix for this target's single-consumer, ephemeral, CHAP-guarded design; see the class doc
comment in `index.mts` for the reasoning and the more rigorous alternative considered.
- Regression test: `loopback.integ.mts`, `describe('connection replacement', ...)` — a second connection is
accepted (not refused) while the first is still open, the first's socket gets torn down, and the new
connection is fully usable (read + logout succeed).
- Re-verified end-to-end after deploying the fix: same S3-backed boot scenario completed successfully
(~5 min total, vs ~20-25s for the equivalent local-storage boot — S3 itself is slow, but the mount no
longer wedges).
- Refined afterwards: the replacement used to happen as soon as the TCP connection was accepted, so any
peer reaching the port (a port scan, a failed CHAP login, a Discovery session) interrupted the session in
use. It now only happens once the new connection has established a Normal session; until then,
connections are bounded by `loginTimeoutMs` and `maxPendingConnections`. Regression tests:
`loopback.integ.mts`, `describe('connection admission', ...)`.
### Independently landed, cross-referenced against this pass's coverage
Five more fixes landed on this branch outside this validation effort. Read in full and checked against what
had (and hadn't) actually been exercised black-box, since two are severe:
1. **`CachedDiskBlockDevice.mts` — acknowledged writes silently and permanently reverted.** A write fully
covering a not-yet-cached block used to skip fetching the source (an "optimization" — the write supplies
all the bytes anyway). But if a _read_ to that same block had already triggered an in-flight fetch, that
fetch could land **after** the write and silently overwrite it with stale source data — guest gets `GOOD`,
data reverts moments later. Fixed by always joining any in-flight fetch before writing. Has a white-box
regression test (pauses a fake disk mid-fetch to force the race). This is exactly the class of bug this
whole effort was looking for, and its vulnerable path — read+write racing on the same never-touched block —
was never actually exercised by this pass's own earlier black-box testing (sequential `dd` and single-job
`fio` both avoid same-offset self-overlap by construction), so the "clean" results up to that point were
consistent with the bug being present, not proof of its absence. Closed that gap afterward (see below).
2. **`connection.mts`/`writePath.mts` — uninitialized heap memory leaked to the LUN on a malformed Data-Out.**
Staging buffer is `Buffer.allocUnsafe`, never zeroed; the old completion check summed received bytes, but
`Buffer.copy` silently clamps on out-of-range/overlapping offsets — a malformed/out-of-order Data-Out
sequence could reach "totalLength received" with gaps of recycled heap memory that then get written to the
LUN and read back by any guest. Real info-disclosure bug, needs a misbehaving/malicious initiator to trigger
(a well-behaved one never sends malformed Data-Out). Fixed with strict contiguity validation; extensively
tested (`connection.test.mts` +586 lines, `writePath.test.mts` +168 lines, both white-box, covering the
malformed-input space directly — not something black-box testing with real tooling can easily reach at all).
3. **`connection.mts` — CmdSN window corrupted by every Data-Out (i.e. every write).** Command-window tracking
read bytes 24-27 as CmdSN off every inbound PDU, but those bytes are reserved on Data-Out per RFC 7143
§11.7 — every write rewound the advertised window. Benign at normal scale (initiators discard the rewind as
stale via serial-number comparison until session CmdSN passes 2³¹) — none of this pass's testing came close
to that scale, so nothing here calls prior results into question. Fixed with an opcode allowlist; has its
own regression test.
4. **`CachedDiskBlockDevice.hydrate()` — no longer throws/orphans on a concurrent unmount.** `hydrate()` now
accepts an `AbortSignal`, stopping at the next block boundary and rejecting cleanly once in-flight blocks
settle, rather than the caller having to serialize hydrate against unmount itself (the orchestration half of
this fix — not running hydrate inside the same long-lived task as the mount — lives in the LiveMount mixin,
outside this package). Tested at the `CachedDiskBlockDevice` level: aborting mid-hydrate keeps everything
already cached, rejects rather than silently returning partial success, and a fresh signal that's already
aborted refuses to start at all.
5. **`initiator.mts` — went silent between commands, dropped by a target's ping timeout.** `IscsiInitiator`'s
read loop used to stop once nothing was outstanding, so during any idle period a target's NOP-In keepalive
sat unread — real targets (LIO's `nopin_response_timeout`, 30s by default) drop sessions with unanswered
pings, with no reconnect logic to recover. Fixed with a loop that keeps draining for the life of the
connection and replies to NOP-In pings (`#pump`/`#replyNop`); added `initiator.integ.mts` (white-box,
real loopback). Never exercised by this pass's own black-box testing at all — the whole effort was
LiveMount-focused, which only ever uses the _target_ role. Attempting to close that gap surfaced two
further, independent findings — see "Initiator interop findings" below.
## Black-box protocol-level testing (standalone target, real tooling)
- **Data integrity under concurrent load**: 30-minute `fio --rw=randrw --bs=4k --iodepth=64 --numjobs=1
--verify=crc32c` soak against the standalone target over real `iscsiadm` loopback — 0 verify errors, ~16.4k
read + ~10.6k write IOPS sustained throughout. (An earlier `numjobs=4` run produced ~116 "bad header"
errors; bisected and reproduced identically against plain local file I/O with zero iSCSI involvement —
a known fio limitation where independent jobs pointed at the same file race each other's own bookkeeping,
not a target bug. `numjobs=1` at higher `iodepth` avoids the false positive while still exercising real
queue depth.)
- **Filesystem round-trip across a full session teardown**: real corpus (2419 files, 53 MB) written, hashed,
then a full cold logout/login cycle plus a host-side page-cache drop before re-reading — all 2419 files
byte-identical.
- **Kill the target process mid-write**: `kill -9`'d mid-transfer with a direct-I/O write in flight. The write
correctly blocked (not a fast, silent "success") until the session recovered, then completed with the exact
intended pattern across the full range — no silent data loss, no torn/partial write at the interruption point.
- **Real cross-machine network partition** (two separate VMs, not loopback): a mid-transfer `iptables DROP`
held for 15s against the target's own port. Compared directly to `tgt` as a baseline over the identical
topology — both stalled through the outage and completed with 0 corrupted bytes once restored; no divergent
behavior from a mature reference implementation.
- **Sequential throughput vs `tgt`** (same two-VM link, plain iSCSI, no XAPI/SM in the path): `@vates/iscsi`
reached ~70% (write) / ~76% (read) of `tgt`'s numbers — noticeably slower but the same order of magnitude,
not a large gap. (A separate, much larger ~10x slowdown measured through the full LiveMount/XAPI/SM stack
was isolated to the host's own `iscsi` SM/tapdisk driver by elimination — outside this package.)
- **Targeted regression testing for bug 1 above**, using a single-job, high-`iodepth`, small-range `fio`
pattern (deliberately avoiding the `numjobs>1` false-positive from the first bullet) to force many concurrent
operations onto the same underlying cache block without needing multiple uncoordinated processes: clean
(0 verify errors) against local storage, against a slow S3-backed remote (a wider fetch-latency window gives
a racing write far more time to land mid-fetch — the exact scenario the bug was about), and against an
actively-running `hydrate()` sweep overlapping guest I/O on the same disk. All three passed.
## Flagged, not fixed
- **`FileBlockDevice.read()` zero-fills past a short/EOF read instead of throwing** — unlike `LocalBlockDevice`
(the actual production cache backend), which throws on the identical condition. Confirmed via grep: only the
standalone CLI tool and this package's own tests use `FileBlockDevice` today, so there's no reachable data-
loss path in production right now — but it's a public export, so a future feature backing a LUN directly
with a file would silently inherit the zero-fill-over-error behavior. This is a design-intent question (is
the CLI tool's "sparse tail" leniency deliberate?), not a clear bug — flagging for a decision rather than
silently changing it.
## Initiator interop findings (this session)
Bug 5's fix (above) had never been exercised against a real target under real conditions — only the white-box
`initiator.integ.mts`. Attempting to close that gap with a live idle-period/NOP-In test against two different
real target implementations surfaced two independent, real findings before the actual keepalive behavior could
be exercised:
- **`tgt` reserves LUN 0 as a built-in, zero-size "controller" device** on every target, and this is not
removable via `tgtadm` (`tgtadm: this logical unit number already exists` / `invalid request` on any attempt
to delete or replace it). `IscsiInitiator.connect()`/`read()` are hardcoded to LUN 0 (matching this package's
own single-LUN-at-0 target design), so `connect()` fails immediately with `CHECK_CONDITION` against `tgt`
regardless of where a real data LUN is added (tried LUN 1). Pure tooling mismatch — `tgt`'s own convention
doesn't fit a client that only ever speaks to LUN 0 — not a bug in either side.
- **LIO (`targetcli-fb`/kernel target) does complete login differently, and `IscsiInitiator` can't get through
it.** With a real data LUN at LUN 0 (LIO doesn't reserve it), `connect()` still fails — `#operationalLogin`
(`initiator.mts`) sends exactly one operational-stage login PDU, requests immediate transit (`T=1`), and only
checks the transit bit on the reply; there's no second round to answer a target's own counter-proposal if it
declines transit on the first exchange. LIO's kernel target does exactly that: it counter-proposes several of
its own operational keys (`MaxBurstLength`, `DefaultTime2Wait`, `DefaultTime2Retain`, `MaxOutstandingR2T`) in
the same response and withholds transit until they're answered, confirmed directly in `dmesg`:
```
No response for proposed key "MaxBurstLength".
No response for proposed key "DefaultTime2Wait".
No response for proposed key "DefaultTime2Retain".
No response for proposed key "MaxOutstandingR2T".
iSCSI Login negotiation failed.
```
This works fine against `tgt` and against this package's own `IscsiTarget` (both transit on the first round),
so it's never been visible before — but it's a **real, previously-unknown limitation of `IscsiInitiator`'s
login state machine**: single operational round-trip only, no handling for a target that insists on its own
multi-round negotiation before agreeing to transit. Worth knowing given the initiator role's real intended
use (reading from a third-party storage array as an initiator) targets unknown-strictness third-party target
software, not `tgt` or this package's own target.
Given both blockers are about _login/LUN setup_, not the keepalive mechanism itself, the actual idle-period/
NOP-In behavior could not be exercised live this session. The white-box `initiator.integ.mts` coverage for
bug 5 stands as the only current test of that specific fix.

View File

@@ -0,0 +1,6 @@
// @ts-check
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
export default tseslint.config(eslint.configs.recommended, tseslint.configs.recommended)

61
@vates/iscsi/package.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "@vates/iscsi",
"version": "0.4.0",
"main": "dist/index.mjs",
"types": "dist/index.d.mts",
"bin": {
"vates-iscsi": "dist/cli.mjs"
},
"license": "ISC",
"private": false,
"type": "module",
"description": "Minimal iSCSI target and initiator: expose or read a single LUN, with one-way CHAP",
"keywords": [
"iscsi",
"target",
"initiator",
"chap",
"scsi",
"lun",
"block",
"storage"
],
"dependencies": {
"@vates/async-each": "^1.0.3",
"@vates/read-chunk": "^1.2.1",
"@xen-orchestra/disk-transform": "^1.3.4",
"@xen-orchestra/log": "^0.7.2",
"promise-toolbox": "^0.21.0"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/node": "^22.0",
"rimraf": "^6.0.1",
"typescript": "~5.6",
"typescript-eslint": "^8.61.0"
},
"scripts": {
"build": "tsc",
"clean": "rimraf dist/",
"dev": "tsc --watch",
"postversion": "npm publish --access public",
"prebuild": "rimraf dist/",
"predev": "yarn run prebuild",
"test": "tsc && node --test \"dist/**/*.test.mjs\"",
"test-integration": "tsc && node --test \"dist/**/*.integ.mjs\""
},
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/iscsi",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/iscsi",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"engines": {
"node": ">=22"
}
}

View File

@@ -0,0 +1,321 @@
import { asyncEach } from '@vates/async-each'
import { createLogger, type Logger } from '@xen-orchestra/log'
import type { ProgressHandler, RandomAccessDisk } from '@xen-orchestra/disk-transform'
import type { BlockDevice } from './backend.mjs'
const log: Logger = createLogger('vates:iscsi:cached-disk-block-device')
const DEFAULT_BLOCK_SIZE = 512
const DEFAULT_HYDRATE_CONCURRENCY = 8
export interface CachedDiskBlockDeviceOptions {
/**
* Source disk, already `init()`ed by the caller. Only ever read from, one
* whole block at a time.
*/
readonly disk: RandomAccessDisk
/**
* Writable store holding what has been read from {@link disk} so far, already
* `open()`ed and at least as large as the disk's virtual size.
*/
readonly cache: BlockDevice
/**
* Logical block size advertised to initiators, in bytes. Defaults to 512.
* The disk's own block size must be a multiple of it.
*/
readonly blockSize?: number
/**
* Notified with the materialized fraction (0..1) every time a new block is
* cached — on demand or through {@link hydrate} — and via `done()` once
* every block has been. Reused from the `disk-transform` export/import
* pipelines rather than inventing a new shape.
*/
readonly progressHandler?: ProgressHandler
/**
* A previously-persisted bitmap to resume from, one byte per block (see
* {@link CachedDiskBlockDevice}'s class doc). Its length must match the
* block count computed from `disk`/`blockSize`; a mismatch (e.g. the disk
* was resized since it was saved) is treated as stale and discarded rather
* than rejected, so a corrupt/outdated resume file never blocks a mount.
*/
readonly initialBitmap?: Buffer
/**
* Notified with a block's index the moment it is newly cached — narrower
* than `progressHandler`, which only carries an aggregate fraction and
* cannot say *which* block just landed. Meant for persisting the bitmap
* one byte at a time; best-effort like `progressHandler`, a failure here
* must never break block materialization.
*/
readonly onBlockCached?: (index: number) => void
}
/**
* A read-write {@link BlockDevice} over a {@link RandomAccessDisk}, backed by
* a local writable store: each source block is materialized into the store
* the first time it's needed, tracked by a bitmap so repeat access never goes
* back to the source. Call {@link hydrate} to force the whole disk in upfront.
*
* The bitmap is one byte per block, not one bit: a caller that persists it
* incrementally (see `onBlockCached`) writes one block's byte at a time, and
* every block's offset must be independent of every other's for concurrent
* writes to be safe. A real bit-packed bitmap fails that: several blocks'
* bits sharing one byte would need a read-modify-write of that byte, which
* two blocks completing concurrently could race on and lose an update. One
* byte per block costs a little more memory (negligible in absolute terms —
* a few hundred KB even for a huge disk) and buys that independence for free.
*
* Blocks the source doesn't have are marked present without being written —
* relies on the store reading as zero where nothing was ever written.
*
* Writes land in the store only; the source is never written to, so the
* mount diverges from it as soon as anything writes.
*/
export class CachedDiskBlockDevice implements BlockDevice {
readonly #disk: RandomAccessDisk
readonly #cache: BlockDevice
readonly #blockSize: number
readonly #progressHandler?: ProgressHandler
readonly #onBlockCached?: (index: number) => void
readonly #initialBitmap?: Buffer
// one byte per source block, 0 or 1, set once the block is in the cache
#bitmap: Buffer = Buffer.alloc(0)
#blockCount = 0
#cachedCount = 0
// source blocks being materialized right now, so concurrent reads of
// overlapping ranges fetch each block once
readonly #inFlight: Map<number, Promise<void>> = new Map()
#diskBlockSize?: number
#size?: number
constructor({
disk,
cache,
blockSize = DEFAULT_BLOCK_SIZE,
progressHandler,
initialBitmap,
onBlockCached,
}: CachedDiskBlockDeviceOptions) {
if (!Number.isInteger(blockSize) || blockSize <= 0) {
throw new Error(`blockSize must be a positive integer, got ${blockSize}`)
}
this.#disk = disk
this.#cache = cache
this.#blockSize = blockSize
this.#progressHandler = progressHandler
this.#initialBitmap = initialBitmap
this.#onBlockCached = onBlockCached
}
/** Read the source geometry and allocate (or resume) the bitmap. */
async open(): Promise<void> {
const blockSize = this.#blockSize
const diskBlockSize = this.#disk.getBlockSize()
if (diskBlockSize % blockSize !== 0) {
throw new Error(`disk block size (${diskBlockSize}) is not a multiple of the LUN block size (${blockSize})`)
}
const size = this.#disk.getVirtualSize()
if (size <= 0 || size % blockSize !== 0) {
throw new Error(`disk virtual size (${size}) is not a positive multiple of the LUN block size (${blockSize})`)
}
if (this.#cache.getSize() < size) {
throw new Error(`cache (${this.#cache.getSize()} bytes) is smaller than the disk (${size} bytes)`)
}
this.#diskBlockSize = diskBlockSize
this.#size = size
this.#blockCount = Math.ceil(size / diskBlockSize)
const initialBitmap = this.#initialBitmap
if (initialBitmap !== undefined && initialBitmap.length === this.#blockCount) {
this.#bitmap = initialBitmap
let cachedCount = 0
for (const byte of initialBitmap) {
if (byte !== 0) {
cachedCount++
}
}
this.#cachedCount = cachedCount
} else {
if (initialBitmap !== undefined) {
log.warn('discarding stale initial bitmap (block count mismatch)', {
gotLength: initialBitmap.length,
expectedLength: this.#blockCount,
})
}
this.#bitmap = Buffer.alloc(this.#blockCount)
}
log.debug('opened', {
size,
blockSize,
diskBlockSize,
blockCount: this.#blockCount,
resumedBlocks: this.#cachedCount,
})
}
getSize(): number {
const size = this.#size
if (size === undefined) {
throw new Error('CachedDiskBlockDevice.open() must be called before I/O')
}
return size
}
getBlockSize(): number {
return this.#blockSize
}
/** How much of the source has been materialized into the cache. */
getMaterialized(): { blocks: number; total: number } {
return { blocks: this.#cachedCount, total: this.#blockCount }
}
/**
* Force every block into the cache — including holes, so `getMaterialized()`
* reaches `total` once done, matching what a full sequential read would
* naturally mark. Already-materialized blocks are skipped, so this resumes
* rather than redoes whatever on-demand reads (or a previous, interrupted
* hydration) already did.
*
* Hydrating a large disk takes hours, so it must be interruptible: `signal`
* stops it at the next block boundary, rejecting once the blocks already in
* flight settle. Nothing is rolled back — every block cached so far stays
* cached, and because this call resumes, aborting and calling again later
* picks up where it stopped rather than starting over.
*/
async hydrate({
concurrency = DEFAULT_HYDRATE_CONCURRENCY,
signal,
}: { concurrency?: number; signal?: AbortSignal } = {}): Promise<void> {
signal?.throwIfAborted()
const blockCount = this.#blockCount
await asyncEach(
(function* allBlockIndexes() {
for (let index = 0; index < blockCount; index++) {
yield index
}
})(),
index => this.#ensureBlock(index),
{ concurrency, signal }
)
}
#isCached(index: number): boolean {
return this.#bitmap[index] !== 0
}
#setCached(index: number): void {
if (!this.#isCached(index)) {
this.#bitmap[index] = 1
this.#cachedCount++
this.#reportProgress()
// best-effort, same contract as #reportProgress: a synchronous throw
// here must never break block materialization. Any async failure (e.g.
// the persisted-bitmap write itself) is the callback's own job to catch.
try {
this.#onBlockCached?.(index)
} catch (error) {
log.warn('onBlockCached failed', { error, index })
}
}
}
// best-effort: a failure here (sync or async) must never break block
// materialization. Wrapping the body in an `async` function, rather than a
// plain try/catch, is what makes a *synchronous* throw from the handler
// land in the same `.catch()` as an asynchronous rejection.
#reportProgress(): void {
const handler = this.#progressHandler
if (handler === undefined) {
return
}
const fraction = this.#cachedCount / this.#blockCount
;(async () => {
await handler.setProgress(fraction)
// every block is in the cache: nothing more will ever be reported
if (fraction === 1) {
await handler.done()
}
})().catch(error => log.warn('progress handler failed', { error }))
}
/**
* Copy one source block into the cache, unless it is already there. Concurrent
* calls for the same block share a single fetch.
*/
#ensureBlock(index: number): Promise<void> {
if (this.#isCached(index)) {
return Promise.resolve()
}
let pending = this.#inFlight.get(index)
if (pending === undefined) {
// the entry goes away whether the fetch succeeded or not, so a failed
// block is retried later instead of being remembered as broken
pending = this.#fetchBlock(index).finally(() => this.#inFlight.delete(index))
this.#inFlight.set(index, pending)
}
return pending
}
async #fetchBlock(index: number): Promise<void> {
const diskBlockSize = this.#diskBlockSize as number
if (this.#disk.hasBlock(index)) {
const offset = index * diskBlockSize
const { data } = await this.#disk.readBlock(index)
// a source block is always full-size: the last one may run past the end of
// the disk, and the cache must not be written beyond it
const length = Math.min(diskBlockSize, this.getSize() - offset)
await this.#cache.write(offset, length < data.length ? data.subarray(0, length) : data)
}
// a block the source does not have stays as the cache has it, which is
// zeroes as long as nothing has written there
this.#setCached(index)
}
#checkRange(offset: number, length: number): void {
const size = this.getSize()
if (offset < 0 || length < 0 || offset + length > size) {
throw new Error(`access of ${length} bytes at ${offset} is out of range (size ${size})`)
}
}
async read(offset: number, length: number): Promise<Buffer> {
this.#checkRange(offset, length)
if (length === 0) {
return Buffer.alloc(0)
}
const diskBlockSize = this.#diskBlockSize as number
const last = Math.floor((offset + length - 1) / diskBlockSize)
for (let index = Math.floor(offset / diskBlockSize); index <= last; index++) {
await this.#ensureBlock(index)
}
return this.#cache.read(offset, length)
}
async write(offset: number, data: Buffer): Promise<void> {
this.#checkRange(offset, data.length)
if (data.length === 0) {
return
}
// every covered block must be materialized first, even the ones this write
// covers entirely: skipping the fetch would let an already in-flight one
// land *after* the payload and overwrite it with the source's bytes.
// (optimization left out on purpose — it has to join the in-flight fetch)
await this.read(offset, data.length)
await this.#cache.write(offset, data)
}
async flush(): Promise<void> {
await this.#cache.flush()
}
async close(): Promise<void> {
try {
await this.#cache.close()
} finally {
await this.#disk.close()
}
}
}

View File

@@ -0,0 +1,605 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { RandomAccessDisk, type DiskBlock, type ProgressHandler } from '@xen-orchestra/disk-transform'
import { CachedDiskBlockDevice } from './CachedDiskBlockDevice.mjs'
import type { BlockDevice } from './backend.mjs'
const DISK_BLOCK_SIZE = 4096
const BLOCK_COUNT = 4
const DISK_SIZE = BLOCK_COUNT * DISK_BLOCK_SIZE
const bytePattern = (i: number) => (i * 31 + 11) & 0xff
/** Sparse source: only blocks 0 and 2 exist, like a differencing chain. */
class StubDisk extends RandomAccessDisk {
readBlockCalls: Array<number> = []
closed = false
/** set to hold every fetch open until it resolves */
pause?: Promise<void>
constructor(readonly allocated: Set<number> = new Set([0, 2])) {
super()
}
getBlockSize(): number {
return DISK_BLOCK_SIZE
}
getVirtualSize(): number {
return DISK_SIZE
}
isDifferencing(): boolean {
return false
}
getBlockIndexes(): Array<number> {
return [...this.allocated]
}
hasBlock(index: number): boolean {
return this.allocated.has(index)
}
async init(): Promise<void> {}
async close(): Promise<void> {
this.closed = true
}
async readBlock(index: number): Promise<DiskBlock> {
if (!this.allocated.has(index)) {
throw new Error(`Block ${index} not found in chain`)
}
this.readBlockCalls.push(index)
if (this.pause !== undefined) {
await this.pause
}
const data = Buffer.alloc(DISK_BLOCK_SIZE)
for (let i = 0; i < data.length; i++) {
data[i] = bytePattern(index * DISK_BLOCK_SIZE + i)
}
return { index, data }
}
}
/** In-memory stand-in for the local VDI. */
class StubCache implements BlockDevice {
readonly content: Buffer
writes: Array<{ offset: number; length: number }> = []
closed = false
flushed = 0
failNextWrite = false
constructor(size = DISK_SIZE) {
this.content = Buffer.alloc(size)
}
getSize(): number {
return this.content.length
}
getBlockSize(): number {
return 512
}
async read(offset: number, length: number): Promise<Buffer> {
return Buffer.from(this.content.subarray(offset, offset + length))
}
async write(offset: number, data: Buffer): Promise<void> {
if (this.failNextWrite) {
this.failNextWrite = false
throw new Error('cache write failed')
}
this.writes.push({ offset, length: data.length })
data.copy(this.content, offset)
}
async flush(): Promise<void> {
this.flushed++
}
async close(): Promise<void> {
this.closed = true
}
}
const expectedAt = (offset: number, length: number, allocated: Set<number>): Buffer => {
const expected = Buffer.alloc(length)
for (let i = 0; i < length; i++) {
const absolute = offset + i
if (allocated.has(Math.floor(absolute / DISK_BLOCK_SIZE))) {
expected[i] = bytePattern(absolute)
}
}
return expected
}
const make = async (allocated?: Set<number>, cacheSize?: number) => {
const disk = new StubDisk(allocated)
const cache = new StubCache(cacheSize)
const device = new CachedDiskBlockDevice({ disk, cache })
await device.open()
return { cache, device, disk }
}
describe('open', () => {
it('exposes the disk capacity and a fully cold bitmap', async () => {
const { device } = await make()
assert.equal(device.getSize(), DISK_SIZE)
assert.equal(device.getBlockSize(), 512)
assert.deepEqual(device.getMaterialized(), { blocks: 0, total: BLOCK_COUNT })
})
it('refuses a cache smaller than the disk', async () => {
const device = new CachedDiskBlockDevice({ disk: new StubDisk(), cache: new StubCache(DISK_SIZE - 512) })
await assert.rejects(device.open(), /smaller than the disk/)
})
it('resumes from an initial bitmap of the right length', async () => {
// all blocks allocated, so the not-yet-cached one below is a real fetch,
// not a hole (which would never call readBlock regardless of the bitmap)
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const initialBitmap = Buffer.alloc(BLOCK_COUNT)
initialBitmap[0] = 1
initialBitmap[2] = 1
const device = new CachedDiskBlockDevice({ disk, cache, initialBitmap })
await device.open()
assert.deepEqual(device.getMaterialized(), { blocks: 2, total: BLOCK_COUNT })
// already marked cached: served from the (empty) cache store, source untouched
assert.deepEqual(await device.read(0, 512), Buffer.alloc(512))
assert.deepEqual(disk.readBlockCalls, [])
// not marked cached: still fetched from the source as usual
assert.deepEqual(await device.read(DISK_BLOCK_SIZE, 512), expectedAt(DISK_BLOCK_SIZE, 512, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [1])
})
it('discards an initial bitmap of the wrong length and starts cold', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const device = new CachedDiskBlockDevice({ disk, cache, initialBitmap: Buffer.alloc(BLOCK_COUNT - 1, 1) })
await device.open()
assert.deepEqual(device.getMaterialized(), { blocks: 0, total: BLOCK_COUNT })
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [0])
})
})
describe('read', () => {
it('materializes a block on first read and serves later ones from the cache', async () => {
const { device, disk, cache } = await make()
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [0])
// the whole source block was written, not just the part that was read
assert.deepEqual(cache.writes, [{ offset: 0, length: DISK_BLOCK_SIZE }])
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
// a second read of a different part of the same block does not go back
assert.deepEqual(await device.read(1024, 512), expectedAt(1024, 512, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [0])
assert.equal(cache.writes.length, 1)
})
it('marks a hole as present without writing it', async () => {
const { device, disk, cache } = await make()
assert.deepEqual(await device.read(DISK_BLOCK_SIZE, 512), Buffer.alloc(512))
assert.deepEqual(disk.readBlockCalls, [])
assert.deepEqual(cache.writes, [])
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
})
it('stitches a range spanning a present and a missing block', async () => {
const { device, disk } = await make()
const offset = DISK_BLOCK_SIZE - 512
const length = DISK_BLOCK_SIZE + 1024
assert.deepEqual(await device.read(offset, length), expectedAt(offset, length, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [0, 2])
})
it('fetches a block once for concurrent overlapping reads', async () => {
const { device, disk } = await make()
// hold the fetch open until both reads are in flight
let release = () => {}
disk.pause = new Promise<void>(resolve => {
release = resolve
})
const first = device.read(0, 1024)
const second = device.read(512, 1024)
await new Promise(resolve => setImmediate(resolve))
release()
assert.deepEqual(await first, expectedAt(0, 1024, disk.allocated))
assert.deepEqual(await second, expectedAt(512, 1024, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [0])
})
it('rejects a read past the end of the disk', async () => {
const { device } = await make()
await assert.rejects(device.read(DISK_SIZE - 256, 512), /out of range/)
})
it('clamps the tail block to the disk size', async () => {
// 3 blocks + half a block, so the last source block runs past the end
class ShortDisk extends StubDisk {
getVirtualSize(): number {
return 3 * DISK_BLOCK_SIZE + 2048
}
}
const disk = new ShortDisk(new Set([3]))
const cache = new StubCache(3 * DISK_BLOCK_SIZE + 2048)
const device = new CachedDiskBlockDevice({ disk, cache })
await device.open()
await device.read(3 * DISK_BLOCK_SIZE, 2048)
assert.deepEqual(cache.writes, [{ offset: 3 * DISK_BLOCK_SIZE, length: 2048 }])
})
})
describe('write', () => {
it('lands in the cache and is readable back, without touching the source', async () => {
const { device, cache, disk } = await make()
const payload = Buffer.alloc(512, 0xee)
await device.write(0, payload)
assert.deepEqual(await device.read(0, 512), payload)
// the block was materialized first, since the write covers only part of it
assert.deepEqual(disk.readBlockCalls, [0])
// ... and the bytes after the write are still the source's
assert.deepEqual(await device.read(512, 512), expectedAt(512, 512, disk.allocated))
assert.equal(cache.closed, false)
})
it('materializes the block even when the write covers it entirely', async () => {
const { device, disk } = await make()
await device.write(0, Buffer.alloc(DISK_BLOCK_SIZE, 0xee))
// the fetch cannot be skipped: see the race covered by the next test
assert.deepEqual(disk.readBlockCalls, [0])
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
})
it('is not overwritten by a fetch already in flight when it lands', async () => {
const { device, disk } = await make()
// hold block 0's fetch open, so the write lands while it is in flight
let release = () => {}
disk.pause = new Promise<void>(resolve => {
release = resolve
})
const reading = device.read(0, 512)
await new Promise(resolve => setImmediate(resolve))
const payload = Buffer.alloc(DISK_BLOCK_SIZE, 0xee)
const writing = device.write(0, payload)
await new Promise(resolve => setImmediate(resolve))
release()
await reading
await writing
// the source block was fetched once, and its bytes did not come back on top
assert.deepEqual(disk.readBlockCalls, [0])
assert.deepEqual(await device.read(0, DISK_BLOCK_SIZE), payload)
})
})
describe('hydrate', () => {
it('materializes every block the source has, holes included', async () => {
const { device, disk, cache } = await make()
await device.hydrate()
assert.deepEqual(device.getMaterialized(), { blocks: BLOCK_COUNT, total: BLOCK_COUNT })
// only the allocated ones were actually fetched...
assert.deepEqual([...disk.readBlockCalls].sort(), [0, 2])
// ...and the whole disk now reads correctly straight from the cache
assert.deepEqual(await cache.read(0, DISK_SIZE), expectedAt(0, DISK_SIZE, disk.allocated))
})
it('skips blocks already materialized by an earlier read', async () => {
const { device, disk } = await make()
await device.read(0, 512)
assert.deepEqual(disk.readBlockCalls, [0])
await device.hydrate()
// block 0 was not fetched a second time
assert.deepEqual(disk.readBlockCalls, [0, 2])
assert.deepEqual(device.getMaterialized(), { blocks: BLOCK_COUNT, total: BLOCK_COUNT })
})
it('is a no-op once fully materialized', async () => {
const { device, disk } = await make()
await device.hydrate()
disk.readBlockCalls.length = 0
await device.hydrate()
assert.deepEqual(disk.readBlockCalls, [])
})
it('stops on an aborted signal, keeping what it already cached', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const device = new CachedDiskBlockDevice({ disk, cache })
await device.open()
// every block but the first parks, so the hydration is still mid-flight
// when it is aborted
let release = () => {}
const parked = new Promise<void>(resolve => {
release = resolve
})
disk.readBlock = async index => {
if (index > 0) {
await parked
}
return { index, data: Buffer.alloc(DISK_BLOCK_SIZE, index) }
}
const controller = new AbortController()
const hydrating = device.hydrate({ concurrency: 1, signal: controller.signal })
for (let turn = 0; turn < 20 && device.getMaterialized().blocks === 0; turn++) {
await new Promise(resolve => setImmediate(resolve))
}
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
controller.abort()
// aborting is a failure, not a silent partial success
await assert.rejects(hydrating)
// and the block already cached stays cached, so a later hydration resumes
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
release() // let the parked fetch finish rather than leaving it dangling
})
it('refuses to start on an already-aborted signal', async () => {
const { device, disk } = await make()
const controller = new AbortController()
controller.abort()
await assert.rejects(device.hydrate({ signal: controller.signal }))
assert.deepEqual(disk.readBlockCalls, [])
assert.deepEqual(device.getMaterialized(), { blocks: 0, total: BLOCK_COUNT })
})
it('respects the concurrency limit', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const device = new CachedDiskBlockDevice({ disk, cache })
await device.open()
let inFlight = 0
let maxInFlight = 0
const releases: Array<() => void> = []
disk.readBlock = async index => {
inFlight++
maxInFlight = Math.max(maxInFlight, inFlight)
await new Promise<void>(resolve => releases.push(resolve))
inFlight--
return { index, data: Buffer.alloc(DISK_BLOCK_SIZE) }
}
const hydrating = device.hydrate({ concurrency: 2 })
// let the first batch start
await new Promise(resolve => setImmediate(resolve))
assert.equal(maxInFlight, 2)
while (releases.length > 0) {
releases.shift()!()
await new Promise(resolve => setImmediate(resolve))
}
await hydrating
assert.equal(maxInFlight, 2)
})
})
describe('progress handler', () => {
class StubProgressHandler implements ProgressHandler {
calls: Array<number> = []
doneCalls = 0
throwSync = false
rejectAsync = false
async setProgress(fraction: number): Promise<void> {
this.calls.push(fraction)
if (this.throwSync) {
throw new Error('sync failure')
}
if (this.rejectAsync) {
throw new Error('async failure')
}
}
done(): void {
this.doneCalls++
}
}
it('reports the materialized fraction as blocks are read, once each', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.read(0, 512)
// let the fire-and-forget notification run
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(progressHandler.calls, [1 / BLOCK_COUNT])
// a hole is a materialization too, just with nothing to write
await device.read(DISK_BLOCK_SIZE, 512)
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(progressHandler.calls, [1 / BLOCK_COUNT, 2 / BLOCK_COUNT])
// re-reading an already materialized block reports nothing new
await device.read(0, 512)
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(progressHandler.calls, [1 / BLOCK_COUNT, 2 / BLOCK_COUNT])
})
it('reports a fully-covered write the same way as a read', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.write(0, Buffer.alloc(DISK_BLOCK_SIZE, 0xee))
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(progressHandler.calls, [1 / BLOCK_COUNT])
})
it('does not let a synchronously throwing handler break the read', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
progressHandler.throwSync = true
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
})
it('does not let a rejecting handler surface as an unhandled rejection', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
progressHandler.rejectAsync = true
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
// give the rejected promise's `.catch` a turn to run before the test ends
await new Promise(resolve => setImmediate(resolve))
})
it('calls done() once every block is cached, via hydrate', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.hydrate()
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(progressHandler.calls, [0.25, 0.5, 0.75, 1])
assert.equal(progressHandler.doneCalls, 1)
})
it('calls done() once every block is cached, via on-demand reads alone', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.read(0, DISK_BLOCK_SIZE * BLOCK_COUNT)
await new Promise(resolve => setImmediate(resolve))
assert.equal(progressHandler.doneCalls, 1)
})
it('does not call done() before every block is cached', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.read(0, 512)
await new Promise(resolve => setImmediate(resolve))
assert.equal(progressHandler.doneCalls, 0)
})
it('does not let a throwing done() surface as an unhandled rejection', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
progressHandler.done = () => {
throw new Error('done failure')
}
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.hydrate()
await new Promise(resolve => setImmediate(resolve))
})
})
describe('onBlockCached', () => {
it('fires with the index of each newly cached block, once each', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const cached: Array<number> = []
const device = new CachedDiskBlockDevice({ disk, cache, onBlockCached: index => cached.push(index) })
await device.open()
await device.read(0, 512)
assert.deepEqual(cached, [0])
// a hole is a materialization too
await device.read(DISK_BLOCK_SIZE, 512)
assert.deepEqual(cached, [0, 1])
// re-reading an already materialized block does not fire again
await device.read(0, 512)
assert.deepEqual(cached, [0, 1])
})
it('does not fire for a block resumed from the initial bitmap', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const initialBitmap = Buffer.alloc(BLOCK_COUNT)
initialBitmap[0] = 1
const cached: Array<number> = []
const device = new CachedDiskBlockDevice({ disk, cache, initialBitmap, onBlockCached: index => cached.push(index) })
await device.open()
await device.read(0, 512)
assert.deepEqual(cached, [])
})
it('does not let a synchronously throwing callback break the read', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const device = new CachedDiskBlockDevice({
disk,
cache,
onBlockCached: () => {
throw new Error('persistence failure')
},
})
await device.open()
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
})
})
describe('flush and close', () => {
it('flushes the cache', async () => {
const { device, cache } = await make()
await device.flush()
assert.equal(cache.flushed, 1)
})
it('closes the cache and the disk', async () => {
const { device, cache, disk } = await make()
await device.close()
assert.equal(cache.closed, true)
assert.equal(disk.closed, true)
})
it('still closes the disk when closing the cache fails', async () => {
const { device, cache, disk } = await make()
cache.close = async () => {
throw new Error('nope')
}
await assert.rejects(device.close(), /nope/)
assert.equal(disk.closed, true)
})
})

View File

@@ -0,0 +1,120 @@
import { createLogger, type Logger } from '@xen-orchestra/log'
import type { RandomAccessDisk } from '@xen-orchestra/disk-transform'
import type { BlockDevice } from './backend.mjs'
const log: Logger = createLogger('vates:iscsi:disk-block-device')
const DEFAULT_BLOCK_SIZE = 512
export interface DiskBlockDeviceOptions {
/**
* Source disk, already `init()`ed by the caller (this adapter never calls
* `init()` — the disk may be part of a chain the caller assembled).
*/
readonly disk: RandomAccessDisk
/**
* Logical block size advertised to initiators, in bytes. Defaults to 512.
* The disk's own block size must be a multiple of it.
*/
readonly blockSize?: number
}
/**
* A read-only {@link BlockDevice} for a {@link RandomAccessDisk} (the inverse
* of {@link IscsiDisk}). Unallocated blocks read as zero; the source must
* never be asked for a block it doesn't have (e.g. `RemoteVhdDiskChain`
* throws).
*
* Nothing is cached — every read re-fetches a whole source block. Put a
* caching decorator in front of the source disk, not here.
*/
export class DiskBlockDevice implements BlockDevice {
readonly #disk: RandomAccessDisk
readonly #blockSize: number
#diskBlockSize?: number
#size?: number
constructor({ disk, blockSize = DEFAULT_BLOCK_SIZE }: DiskBlockDeviceOptions) {
if (!Number.isInteger(blockSize) || blockSize <= 0) {
throw new Error(`blockSize must be a positive integer, got ${blockSize}`)
}
this.#disk = disk
this.#blockSize = blockSize
}
/**
* Read the source disk's geometry and check it can be exposed as a LUN.
* Awaited by the target before it serves any I/O.
*/
async open(): Promise<void> {
const blockSize = this.#blockSize
const diskBlockSize = this.#disk.getBlockSize()
if (diskBlockSize % blockSize !== 0) {
throw new Error(`disk block size (${diskBlockSize}) is not a multiple of the LUN block size (${blockSize})`)
}
const size = this.#disk.getVirtualSize()
if (size <= 0 || size % blockSize !== 0) {
throw new Error(`disk virtual size (${size}) is not a positive multiple of the LUN block size (${blockSize})`)
}
this.#diskBlockSize = diskBlockSize
this.#size = size
log.debug('opened', { size, blockSize, diskBlockSize })
}
getSize(): number {
const size = this.#size
if (size === undefined) {
throw new Error('DiskBlockDevice.open() must be called before I/O')
}
return size
}
getBlockSize(): number {
return this.#blockSize
}
async read(offset: number, length: number): Promise<Buffer> {
const size = this.getSize()
if (offset < 0 || length < 0 || offset + length > size) {
throw new Error(`read of ${length} bytes at ${offset} is out of range (size ${size})`)
}
if (length === 0) {
return Buffer.alloc(0)
}
const diskBlockSize = this.#diskBlockSize as number
// zero-filled: unallocated blocks, and any source block shorter than
// announced, are left as zeroes
const result = Buffer.alloc(length)
const end = offset + length
const lastIndex = Math.floor((end - 1) / diskBlockSize)
for (let index = Math.floor(offset / diskBlockSize); index <= lastIndex; index++) {
if (!this.#disk.hasBlock(index)) {
continue
}
const blockStart = index * diskBlockSize
const from = Math.max(offset, blockStart)
const to = Math.min(end, blockStart + diskBlockSize)
const { data } = await this.#disk.readBlock(index)
data.copy(result, from - offset, from - blockStart, to - blockStart)
}
return result
}
// the target refuses WRITE commands before they reach `write()`
readonly readOnly = true
/** Always throws: the source disk is read-only. */
async write(): Promise<void> {
throw new Error('DiskBlockDevice is read-only')
}
async flush(): Promise<void> {
// nothing to flush: read-only
}
async close(): Promise<void> {
await this.#disk.close()
}
}

View File

@@ -0,0 +1,169 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { RandomAccessDisk, type DiskBlock } from '@xen-orchestra/disk-transform'
import { DiskBlockDevice } from './DiskBlockDevice.mjs'
const BLOCK_SIZE = 4096
/**
* Sparse in-memory {@link RandomAccessDisk}: `blocks` maps a block index to its
* content, every other index is unallocated and must never be read (the real
* `RemoteVhdDiskChain` throws in that case, which is what this asserts).
*/
class StubDisk extends RandomAccessDisk {
readBlockCalls: Array<number> = []
closed = false
constructor(
readonly blocks: Map<number, Buffer>,
readonly virtualSize: number,
readonly blockSize: number = BLOCK_SIZE
) {
super()
}
getBlockSize(): number {
return this.blockSize
}
getVirtualSize(): number {
return this.virtualSize
}
isDifferencing(): boolean {
return false
}
getBlockIndexes(): Array<number> {
return [...this.blocks.keys()]
}
hasBlock(index: number): boolean {
return this.blocks.has(index)
}
async init(): Promise<void> {}
async close(): Promise<void> {
this.closed = true
}
async readBlock(index: number): Promise<DiskBlock> {
const data = this.blocks.get(index)
if (data === undefined) {
throw new Error(`Block ${index} not found`)
}
this.readBlockCalls.push(index)
return { index, data }
}
}
const filled = (byte: number, length = BLOCK_SIZE) => Buffer.alloc(length, byte)
// 4 blocks, only 0 and 2 allocated
const makeDevice = async (blockSize?: number) => {
const disk = new StubDisk(
new Map([
[0, filled(0xaa)],
[2, filled(0xcc)],
]),
4 * BLOCK_SIZE
)
const device = new DiskBlockDevice({ disk, blockSize })
await device.open()
return { disk, device }
}
describe('open', () => {
it('exposes the disk capacity and the requested block size', async () => {
const { device } = await makeDevice()
assert.equal(device.getSize(), 4 * BLOCK_SIZE)
assert.equal(device.getBlockSize(), 512)
})
it('rejects a disk block size that is not a multiple of the LUN block size', async () => {
const disk = new StubDisk(new Map(), 4096, 600)
await assert.rejects(new DiskBlockDevice({ disk }).open(), /not a multiple of the LUN block size/)
})
it('rejects a capacity that is not a multiple of the LUN block size', async () => {
const disk = new StubDisk(new Map(), BLOCK_SIZE + 100)
await assert.rejects(new DiskBlockDevice({ disk }).open(), /not a positive multiple/)
})
it('rejects an invalid block size', () => {
const disk = new StubDisk(new Map(), BLOCK_SIZE)
assert.throws(() => new DiskBlockDevice({ disk, blockSize: 0 }), /positive integer/)
})
it('refuses I/O before open()', () => {
const disk = new StubDisk(new Map(), BLOCK_SIZE)
assert.throws(() => new DiskBlockDevice({ disk }).getSize(), /open\(\) must be called/)
})
})
describe('read', () => {
it('reads a whole allocated block', async () => {
const { device } = await makeDevice()
assert.deepEqual(await device.read(0, BLOCK_SIZE), filled(0xaa))
})
it('reads an unaligned slice inside one block', async () => {
const { device, disk } = await makeDevice()
assert.deepEqual(await device.read(BLOCK_SIZE * 2 + 512, 1024), filled(0xcc, 1024))
// a single source block was fetched, not the whole disk
assert.deepEqual(disk.readBlockCalls, [2])
})
it('zero-fills unallocated blocks instead of reading them', async () => {
const { device, disk } = await makeDevice()
assert.deepEqual(await device.read(BLOCK_SIZE, BLOCK_SIZE), Buffer.alloc(BLOCK_SIZE))
assert.deepEqual(disk.readBlockCalls, [])
})
it('stitches a range spanning allocated and unallocated blocks', async () => {
const { device } = await makeDevice()
const data = await device.read(BLOCK_SIZE - 512, BLOCK_SIZE + 1024)
assert.deepEqual(
data,
Buffer.concat([
filled(0xaa, 512), // tail of block 0
Buffer.alloc(BLOCK_SIZE), // block 1, unallocated
filled(0xcc, 512), // head of block 2
])
)
})
it('reads the last block of the disk', async () => {
const { device } = await makeDevice()
assert.deepEqual(await device.read(3 * BLOCK_SIZE, BLOCK_SIZE), Buffer.alloc(BLOCK_SIZE))
})
it('returns an empty buffer for a zero-length read', async () => {
const { device, disk } = await makeDevice()
assert.equal((await device.read(0, 0)).length, 0)
assert.deepEqual(disk.readBlockCalls, [])
})
it('rejects a read past the end of the disk', async () => {
const { device } = await makeDevice()
await assert.rejects(device.read(4 * BLOCK_SIZE - 512, 1024), /out of range/)
await assert.rejects(device.read(-512, 512), /out of range/)
})
it('zero-pads a source block shorter than announced', async () => {
const disk = new StubDisk(new Map([[0, filled(0xaa, 1024)]]), BLOCK_SIZE)
const device = new DiskBlockDevice({ disk })
await device.open()
assert.deepEqual(await device.read(0, BLOCK_SIZE), Buffer.concat([filled(0xaa, 1024)], BLOCK_SIZE))
})
})
describe('write', () => {
it('always fails', async () => {
const { device } = await makeDevice()
await assert.rejects(device.write(), /read-only/)
})
})
describe('close', () => {
it('closes the underlying disk', async () => {
const { device, disk } = await makeDevice()
await device.close()
assert.equal(disk.closed, true)
})
})

View File

@@ -0,0 +1,98 @@
import { RandomAccessDisk, type DiskBlock } from '@xen-orchestra/disk-transform'
import { IscsiInitiator, type IscsiInitiatorOptions } from './initiator.mjs'
export interface IscsiDiskOptions {
/**
* Block size, in bytes, presented to the disk-transform pipeline. Must be a
* multiple of the LUN's logical block size (checked at {@link IscsiDisk.init}).
* A single SCSI READ(16) fetches one whole block.
*/
readonly blockSize: number
}
/**
* A {@link RandomAccessDisk} backed by a remote iSCSI LUN read through an
* {@link IscsiInitiator} — the iSCSI analogue of `@vates/nbd-client`'s `NbdDisk`.
* It plugs a target LUN into the `@xen-orchestra/disk-transform` pipeline
* (`ReadAhead`, `DiskLargerBlock`, block generator) unchanged.
*
* First implementation: the LUN is treated as fully allocated — every block is
* reported as present (dense, like `RawDisk`). A future version can narrow this
* with a changed-block map (e.g. a Pure "Volume Difference"). Read-only.
*/
export class IscsiDisk extends RandomAccessDisk {
readonly #options: IscsiInitiatorOptions
readonly #blockSize: number
#initiator?: IscsiInitiator
constructor(options: IscsiInitiatorOptions, { blockSize }: IscsiDiskOptions) {
super()
this.#options = options
this.#blockSize = blockSize
}
async init(): Promise<void> {
const initiator = new IscsiInitiator(this.#options)
await initiator.connect()
const lunBlockSize = initiator.getBlockSize()
if (this.#blockSize % lunBlockSize !== 0) {
await initiator.close()
throw new Error(`block size ${this.#blockSize} is not a multiple of the LUN block size ${lunBlockSize}`)
}
this.#initiator = initiator
}
async close(): Promise<void> {
const initiator = this.#initiator
this.#initiator = undefined
await initiator?.close()
}
#requireInitiator(): IscsiInitiator {
const initiator = this.#initiator
if (initiator === undefined) {
throw new Error('IscsiDisk.init() must be called first')
}
return initiator
}
getBlockSize(): number {
return this.#blockSize
}
getVirtualSize(): number {
return this.#requireInitiator().getSize()
}
isDifferencing(): boolean {
return false
}
getBlockIndexes(): Array<number> {
const count = Math.ceil(this.getVirtualSize() / this.#blockSize)
const indexes = new Array<number>(count)
for (let i = 0; i < count; i++) {
indexes[i] = i
}
return indexes
}
hasBlock(index: number): boolean {
return index >= 0 && index * this.#blockSize < this.getVirtualSize()
}
async readBlock(index: number): Promise<DiskBlock> {
const initiator = this.#requireInitiator()
const size = this.getVirtualSize()
const offset = index * this.#blockSize
const length = Math.min(this.#blockSize, size - offset)
let data = await initiator.read(offset, length)
if (data.length < this.#blockSize) {
// Last (short) block on a LUN whose capacity is not a multiple of the
// disk block size: zero-pad the tail to a full block.
data = Buffer.concat([data], this.#blockSize)
}
return { index, data }
}
}

View File

@@ -0,0 +1,129 @@
import assert from 'node:assert/strict'
import { execFile, execFileSync } from 'node:child_process'
import { mkdtemp, rm, truncate, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { after, before, describe, it } from 'node:test'
import { FileBlockDevice } from './index.mjs'
const execFileP = promisify(execFile)
const BLOCK_SIZE = 512
const SIZE = 4 * 1024 * 1024 // 4 MiB
/**
* Why this cannot run here, or `false` if it can. A real block device is the
* whole point, and the cheapest one to make is a loop device — which needs root
* and `losetup`.
*/
function skipReason(): string | false {
if (process.getuid === undefined || process.getuid() !== 0) {
return 'requires root to attach a loop device'
}
try {
execFileSync('losetup', ['--version'], { stdio: 'ignore' })
} catch {
return 'requires util-linux (losetup not found)'
}
return false
}
const bytePattern = (i: number) => (i * 31 + 11) & 0xff
describe('FileBlockDevice on a loop device', { skip: skipReason() }, () => {
let dir: string
let devicePath: string
before(async () => {
dir = await mkdtemp(join(tmpdir(), 'vates-iscsi-loop-'))
const backing = join(dir, 'backing.img')
await writeFile(backing, '')
await truncate(backing, SIZE)
const { stdout } = await execFileP('losetup', ['--find', '--show', backing])
devicePath = stdout.trim()
})
after(async () => {
if (devicePath !== undefined) {
await execFileP('losetup', ['--detach', devicePath])
}
await rm(dir, { force: true, recursive: true })
})
const withDevice = async (fn: (device: FileBlockDevice) => Promise<void>): Promise<void> => {
const device = new FileBlockDevice({ path: devicePath, size: SIZE })
await device.open()
try {
await fn(device)
} finally {
await device.close()
}
}
it('accepts the device node and reports the capacity it was given', async () => {
await withDevice(async device => {
assert.equal(device.getSize(), SIZE)
assert.equal(device.getBlockSize(), BLOCK_SIZE)
})
})
it('round-trips block-aligned data', async () => {
await withDevice(async device => {
const payload = Buffer.alloc(8 * BLOCK_SIZE)
for (let i = 0; i < payload.length; i++) {
payload[i] = bytePattern(i)
}
await device.write(16 * BLOCK_SIZE, payload)
assert.deepEqual(await device.read(16 * BLOCK_SIZE, payload.length), payload)
})
})
it('round-trips data straddling block boundaries, at an unaligned offset', async () => {
await withDevice(async device => {
const payload = Buffer.from('a payload that is not a whole number of sectors long')
const offset = 3 * BLOCK_SIZE + 17
await device.write(offset, payload)
assert.deepEqual(await device.read(offset, payload.length), payload)
// the surrounding sector was read-modify-written by the kernel, not clobbered
assert.deepEqual(await device.read(3 * BLOCK_SIZE, 17), Buffer.alloc(17))
})
})
it('survives a reopen, which is what makes it a cache', async () => {
const payload = Buffer.alloc(BLOCK_SIZE, 0x5a)
await withDevice(device => device.write(2 * BLOCK_SIZE, payload))
await withDevice(async device => {
await device.flush()
assert.deepEqual(await device.read(2 * BLOCK_SIZE, BLOCK_SIZE), payload)
})
})
it('reads a large range in one call', async () => {
await withDevice(async device => {
const payload = Buffer.alloc(SIZE / 2)
for (let i = 0; i < payload.length; i++) {
payload[i] = bytePattern(i + 7)
}
await device.write(0, payload)
assert.deepEqual(await device.read(0, payload.length), payload)
})
})
it('throws on a read running past the end, instead of zero-filling it', async () => {
// the behaviour that sets the block device mode apart: a short read on a
// device of a known size is an I/O error, never a sparse tail
await withDevice(async device => {
await assert.rejects(device.read(SIZE, BLOCK_SIZE), /ended after 0 bytes/)
await assert.rejects(device.read(SIZE - BLOCK_SIZE, 2 * BLOCK_SIZE), /ended after 512 bytes/)
})
})
it('flushes without error', async () => {
await withDevice(async device => {
await device.write(0, Buffer.alloc(BLOCK_SIZE, 0xff))
await device.flush()
})
})
})

View File

@@ -0,0 +1,175 @@
import { open, type FileHandle } from 'node:fs/promises'
/**
* Byte-range random-access store backing a single iSCSI LUN.
*
* The protocol layer translates SCSI READ/WRITE CDBs (LBA + block count) into
* byte offsets/lengths against this interface, so an implementation only has to
* provide flat random access — it is intentionally decoupled from the wire
* code. `DiskBlockDevice` is such an implementation, over
* `@xen-orchestra/disk-transform`'s `RandomAccessDisk` (VHD/raw/NBD).
*/
export interface BlockDevice {
/** Optional one-time initialization, awaited by the target before serving I/O. */
open?(): Promise<void>
/** Total capacity in bytes. Must be a multiple of {@link BlockDevice.getBlockSize}. */
getSize(): number
/** Logical block size in bytes (typically 512). */
getBlockSize(): number
/**
* When true, the LUN is reported as write-protected and WRITE commands are
* refused up front (DATA PROTECT), without soliciting their data.
*/
readonly readOnly?: boolean
/** Read exactly `length` bytes starting at byte `offset`. */
read(offset: number, length: number): Promise<Buffer>
/** Write `data` starting at byte `offset`. */
write(offset: number, data: Buffer): Promise<void>
/** Flush any buffered data to stable storage (SYNCHRONIZE CACHE). */
flush(): Promise<void>
/** Release underlying resources. */
close(): Promise<void>
}
const DEFAULT_BLOCK_SIZE = 512
export interface FileBlockDeviceOptions {
/**
* Path to the backing file. It must already exist and be sized to capacity —
* or be a block device node, see {@link FileBlockDeviceOptions.size}.
*/
readonly path: string
/** Logical block size in bytes. Defaults to 512. */
readonly blockSize?: number
/**
* Capacity in bytes, for a block device node (e.g. a VDI hot-plugged onto the
* appliance's VM, `/dev/xvdX`), whose inode reports a size of 0.
*
* Setting it switches to the block device mode:
* - `path` must be a block device: a typo naming the wrong node is the
* difference between a cache and a destroyed disk;
* - a short read is an I/O error, not a sparse tail: zero-filling it would hand
* the initiator fabricated data.
*/
readonly size?: number
}
/**
* A {@link BlockDevice} backed by a regular (sparse) file, or a block device
* node, opened read/write.
*
* The file must already exist and be sized to the desired capacity (e.g. via
* `fs.truncate`); this keeps the device a pure data store with no provisioning
* policy of its own.
*
* I/O is buffered, never `O_DIRECT`, which would need buffers aligned to the
* logical block size that Node cannot allocate.
*/
export class FileBlockDevice implements BlockDevice {
readonly #path: string
readonly #blockSize: number
readonly #isBlockDevice: boolean
#handle?: FileHandle
#size = 0
constructor({ path, blockSize = DEFAULT_BLOCK_SIZE, size }: FileBlockDeviceOptions) {
if (!Number.isInteger(blockSize) || blockSize <= 0) {
throw new Error(`blockSize must be a positive integer, got ${blockSize}`)
}
if (size !== undefined) {
if (!Number.isInteger(size) || size <= 0) {
throw new Error(`size must be a positive integer, got ${size}`)
}
if (size % blockSize !== 0) {
throw new Error(`size (${size}) is not a multiple of block size (${blockSize})`)
}
this.#size = size
}
this.#path = path
this.#blockSize = blockSize
this.#isBlockDevice = size !== undefined
}
/** Open the backing file and read its current size. Must be called before any I/O. */
async open(): Promise<void> {
if (this.#handle !== undefined) {
return
}
const handle = await open(this.#path, 'r+')
try {
const stats = await handle.stat()
if (this.#isBlockDevice) {
if (!stats.isBlockDevice()) {
throw new Error(`${this.#path} is not a block device`)
}
} else {
if (stats.size % this.#blockSize !== 0) {
throw new Error(`backing file size (${stats.size}) is not a multiple of block size (${this.#blockSize})`)
}
this.#size = stats.size
}
this.#handle = handle
} catch (error) {
await handle.close()
throw error
}
}
#requireHandle(): FileHandle {
const handle = this.#handle
if (handle === undefined) {
throw new Error('FileBlockDevice.open() must be called before I/O')
}
return handle
}
getSize(): number {
return this.#size
}
getBlockSize(): number {
return this.#blockSize
}
async read(offset: number, length: number): Promise<Buffer> {
const handle = this.#requireHandle()
const buffer = Buffer.alloc(length, 0)
let read = 0
// A single fh.read may return fewer bytes than requested; loop to fill.
while (read < length) {
const { bytesRead } = await handle.read(buffer, read, length - read, offset + read)
if (bytesRead === 0) {
if (this.#isBlockDevice) {
// the device is exactly as long as the caller said: the I/O failed
throw new Error(`read of ${length} bytes at ${offset} on ${this.#path} ended after ${read} bytes`)
}
// Reading past EOF (sparse tail): zero-fill the remainder.
buffer.fill(0, read)
break
}
read += bytesRead
}
return buffer
}
async write(offset: number, data: Buffer): Promise<void> {
const handle = this.#requireHandle()
let written = 0
while (written < data.length) {
const { bytesWritten } = await handle.write(data, written, data.length - written, offset + written)
written += bytesWritten
}
}
async flush(): Promise<void> {
await this.#requireHandle().sync()
}
async close(): Promise<void> {
const handle = this.#handle
if (handle !== undefined) {
this.#handle = undefined
await handle.close()
}
}
}

View File

@@ -0,0 +1,173 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { mkdtemp, rm, truncate, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { FileBlockDevice } from './backend.mjs'
const BLOCK_SIZE = 512
/** A temporary directory removed whatever the test does. */
const withTmpDir = async (fn: (dir: string) => Promise<void>): Promise<void> => {
const dir = await mkdtemp(join(tmpdir(), 'vates-iscsi-backend-'))
try {
await fn(dir)
} finally {
await rm(dir, { force: true, recursive: true })
}
}
/** A sparse file of `size` bytes, as the class requires: existing and pre-sized. */
const makeFile = async (dir: string, size: number, name = 'lun.img'): Promise<string> => {
const path = join(dir, name)
await writeFile(path, '')
await truncate(path, size)
return path
}
describe('FileBlockDevice', () => {
it('rejects a non-positive-integer block size', () => {
assert.throws(() => new FileBlockDevice({ path: '/nope', blockSize: 0 }), /positive integer/)
assert.throws(() => new FileBlockDevice({ path: '/nope', blockSize: -1 }), /positive integer/)
assert.throws(() => new FileBlockDevice({ path: '/nope', blockSize: 1.5 }), /positive integer/)
})
it('reads the capacity from the backing file', async () => {
await withTmpDir(async dir => {
const device = new FileBlockDevice({ path: await makeFile(dir, 4 * BLOCK_SIZE) })
await device.open()
try {
assert.equal(device.getSize(), 4 * BLOCK_SIZE)
assert.equal(device.getBlockSize(), BLOCK_SIZE)
} finally {
await device.close()
}
})
})
it('refuses a file whose size is not a multiple of the block size', async () => {
await withTmpDir(async dir => {
const device = new FileBlockDevice({ path: await makeFile(dir, BLOCK_SIZE + 1) })
await assert.rejects(device.open(), /not a multiple of block size/)
})
})
it('throws on I/O before open()', async () => {
const device = new FileBlockDevice({ path: '/nope' })
await assert.rejects(device.read(0, BLOCK_SIZE), /must be called before I\/O/)
await assert.rejects(device.write(0, Buffer.alloc(BLOCK_SIZE)), /must be called before I\/O/)
await assert.rejects(device.flush(), /must be called before I\/O/)
})
it('round-trips a write, at an offset and unaligned', async () => {
await withTmpDir(async dir => {
const device = new FileBlockDevice({ path: await makeFile(dir, 4 * BLOCK_SIZE) })
await device.open()
try {
const payload = Buffer.from('hello, lun')
await device.write(BLOCK_SIZE + 3, payload)
assert.deepEqual(await device.read(BLOCK_SIZE + 3, payload.length), payload)
// the bytes around it are untouched
assert.deepEqual(await device.read(BLOCK_SIZE, 3), Buffer.alloc(3))
} finally {
await device.close()
}
})
})
it('zero-fills the sparse tail of a read past EOF', async () => {
await withTmpDir(async dir => {
// a hole: nothing was ever written, so the whole file reads as zeroes
const device = new FileBlockDevice({ path: await makeFile(dir, 2 * BLOCK_SIZE) })
await device.open()
try {
assert.deepEqual(await device.read(0, 2 * BLOCK_SIZE), Buffer.alloc(2 * BLOCK_SIZE))
// past the end of the file, `fh.read` reports 0 bytes read: the remainder is
// zero-filled rather than being reported as an error
assert.deepEqual(await device.read(2 * BLOCK_SIZE, BLOCK_SIZE), Buffer.alloc(BLOCK_SIZE))
} finally {
await device.close()
}
})
})
it('reads back a large buffer in full', async () => {
await withTmpDir(async dir => {
const size = 1024 * BLOCK_SIZE
const device = new FileBlockDevice({ path: await makeFile(dir, size) })
await device.open()
try {
const payload = Buffer.alloc(size)
for (let i = 0; i < payload.length; i++) {
payload[i] = (i * 31 + 11) & 0xff
}
await device.write(0, payload)
assert.deepEqual(await device.read(0, size), payload)
} finally {
await device.close()
}
})
})
it('flushes and closes, and tolerates being opened or closed twice', async () => {
await withTmpDir(async dir => {
const device = new FileBlockDevice({ path: await makeFile(dir, BLOCK_SIZE) })
await device.open()
await device.open() // no-op
await device.write(0, Buffer.alloc(BLOCK_SIZE, 0xaa))
await device.flush()
await device.close()
await device.close() // no-op
await assert.rejects(device.read(0, BLOCK_SIZE), /must be called before I\/O/)
})
})
it('leaves no handle behind when open() fails', async () => {
await withTmpDir(async dir => {
const device = new FileBlockDevice({ path: await makeFile(dir, BLOCK_SIZE + 1) })
await assert.rejects(device.open())
// the handle opened before the size check was closed again
await assert.rejects(device.read(0, BLOCK_SIZE), /must be called before I\/O/)
})
})
})
// A real device node cannot be created without root, so what is covered here is
// everything around the I/O: the guards, the capacity, and the lifecycle. The
// I/O itself is covered by `backend.integ.mts`, against a loop device.
describe('FileBlockDevice with a size, over a block device', () => {
it('rejects a non-positive-integer size', () => {
for (const size of [0, -BLOCK_SIZE, 1.5]) {
assert.throws(() => new FileBlockDevice({ path: '/dev/null', size }), /size must be a positive integer/)
}
})
it('rejects a size that is not a multiple of the block size', () => {
assert.throws(
() => new FileBlockDevice({ path: '/dev/null', size: BLOCK_SIZE + 1 }),
/is not a multiple of block size/
)
})
it('reports the capacity it was given, never the inode', () => {
// nothing may be read from the inode, which for a real device node reports 0
const device = new FileBlockDevice({ path: '/dev/null', size: 4 * BLOCK_SIZE })
assert.equal(device.getSize(), 4 * BLOCK_SIZE)
assert.equal(device.getBlockSize(), BLOCK_SIZE)
})
it('refuses to open a path that is not a block device', async () => {
await withTmpDir(async dir => {
const path = await makeFile(dir, 4 * BLOCK_SIZE)
const device = new FileBlockDevice({ path, size: 4 * BLOCK_SIZE })
// the guard against a misconfigured path naming, say, a real disk's
// filesystem instead of the cache device
await assert.rejects(device.open(), new RegExp(`${path} is not a block device`))
// and the handle it opened to check was closed again
await assert.rejects(device.read(0, BLOCK_SIZE), /must be called before I\/O/)
})
})
})

90
@vates/iscsi/src/chap.mts Normal file
View File

@@ -0,0 +1,90 @@
// Shared CHAP (RFC 1994) primitives for both login directions: the target as
// authenticator, the initiator as responder. Kept in one place so the MD5
// computation and large-binary codec agree byte-for-byte between the two
// (cross-tested against each other in loopback).
//
// RFC 7143 §11.1.4 pins CHAP to MD5 (algorithm id 5) — a wire-format
// requirement, not a security choice.
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
/** The only CHAP algorithm iSCSI defines / we support: MD5 (RFC 7143 §11.1.4). */
export const CHAP_ALGORITHM_MD5 = 5
/** Default challenge length in bytes; 16 is what open-iscsi and most targets use. */
const DEFAULT_CHALLENGE_LENGTH = 16
/**
* Compute a CHAP response: `MD5(id ‖ secret ‖ challenge)` (RFC 1994 §4.1).
*
* `id` is the single CHAP_I octet (0-255) — the raw byte, NOT its ASCII decimal.
* `secret` is the shared secret (a string is hashed as its UTF-8 bytes).
*/
export function computeResponse(id: number, secret: string | Buffer, challenge: Buffer): Buffer {
return createHash('md5')
.update(Buffer.from([id & 0xff]))
.update(secret)
.update(challenge)
.digest()
}
/**
* Encode a binary value (CHAP_C / CHAP_R) as an iSCSI hex-constant, e.g.
* `0xa1b2…` (RFC 7143 §5.1). We always emit hex; {@link decodeChapValue} accepts
* both hex and base64 so we interoperate with peers that send either.
*/
export function encodeChapValue(value: Buffer): string {
return '0x' + value.toString('hex')
}
/**
* Decode an iSCSI large-binary value: `0x`/`0X` hex-constant or `0b`/`0B`
* base64-constant (RFC 7143 §5.1). Throws on an unrecognized encoding.
*/
export function decodeChapValue(value: string): Buffer {
const prefix = value.slice(0, 2)
const body = value.slice(2)
if (prefix === '0x' || prefix === '0X') {
return Buffer.from(body, 'hex')
}
if (prefix === '0b' || prefix === '0B') {
return Buffer.from(body, 'base64')
}
throw new Error(`unsupported CHAP value encoding: ${value.slice(0, 2)}…`)
}
/** Generate a random CHAP challenge (CHAP_C). */
export function generateChallenge(length: number = DEFAULT_CHALLENGE_LENGTH): Buffer {
return randomBytes(length)
}
/** Generate a random CHAP identifier (CHAP_I), a single octet. */
export function generateId(): number {
return randomBytes(1)[0]
}
/** Parse a CHAP_A value (`"5"` or a `"5,6,7"` preference list) into algorithm ids. */
export function parseAlgorithmList(value: string): number[] {
return value
.split(',')
.map(part => Number.parseInt(part.trim(), 10))
.filter(id => Number.isInteger(id))
}
/**
* Pick the algorithm we will use from the ones the peer offered: MD5 if present,
* else `undefined` (we only implement MD5, so an offer without it must fail —
* never fall back to the first offered id).
*/
export function selectAlgorithm(offered: readonly number[]): number | undefined {
return offered.includes(CHAP_ALGORITHM_MD5) ? CHAP_ALGORITHM_MD5 : undefined
}
/**
* Constant-time comparison of an expected response against a received one. A
* wrong-length `got` (or the length-mismatch `timingSafeEqual` would throw on)
* counts as a failed authentication, not an exception.
*/
export function verifyResponse(expected: Buffer, got: Buffer): boolean {
return expected.length === got.length && timingSafeEqual(expected, got)
}

View File

@@ -0,0 +1,108 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { describe, it } from 'node:test'
import {
CHAP_ALGORITHM_MD5,
computeResponse,
decodeChapValue,
encodeChapValue,
generateChallenge,
generateId,
parseAlgorithmList,
selectAlgorithm,
verifyResponse,
} from './chap.mjs'
describe('computeResponse', () => {
// Known-answer vector: MD5(0x01 ‖ "s3cr3t" ‖ <16-byte challenge>), computed
// independently so this pins the exact byte layout (id octet first), not MD5
// against itself.
const id = 1
const secret = 's3cr3t'
const challenge = Buffer.from('0102030405060708090a0b0c0d0e0f10', 'hex')
const expected = 'fc14fb49f184c847514a10278d75a10f'
it('hashes id ‖ secret ‖ challenge (RFC 1994)', () => {
assert.equal(computeResponse(id, secret, challenge).toString('hex'), expected)
})
it('treats a string secret as its UTF-8 bytes', () => {
assert.deepEqual(
computeResponse(id, Buffer.from(secret, 'utf8'), challenge),
computeResponse(id, secret, challenge)
)
})
it('uses the id as a raw octet, not its ASCII decimal', () => {
// computeResponse(1, …) must hash the byte 0x01, whereas hashing the ASCII
// text "1" would hash 0x31 — the two must differ.
const rawOctet = createHash('md5')
.update(Buffer.from([1]))
.update(secret)
.update(challenge)
.digest()
const asciiText = createHash('md5').update(Buffer.from('1', 'ascii')).update(secret).update(challenge).digest()
assert.deepEqual(computeResponse(1, secret, challenge), rawOctet)
assert.notDeepEqual(computeResponse(1, secret, challenge), asciiText)
})
})
describe('CHAP value codec', () => {
it('encodes as a 0x hex-constant', () => {
assert.equal(encodeChapValue(Buffer.from([0xab, 0xcd, 0x01])), '0xabcd01')
})
it('round-trips through hex', () => {
const value = generateChallenge()
assert.deepEqual(decodeChapValue(encodeChapValue(value)), value)
})
it('decodes a 0b base64-constant', () => {
const raw = Buffer.from('0102030405060708090a0b0c0d0e0f10', 'hex')
assert.deepEqual(decodeChapValue('0b' + raw.toString('base64')), raw)
})
it('accepts uppercase 0X / 0B prefixes', () => {
assert.deepEqual(decodeChapValue('0Xabcd'), Buffer.from('abcd', 'hex'))
assert.deepEqual(decodeChapValue('0B' + Buffer.from([1, 2]).toString('base64')), Buffer.from([1, 2]))
})
it('rejects an unknown encoding', () => {
assert.throws(() => decodeChapValue('12345'), /unsupported CHAP value encoding/)
})
})
describe('algorithm selection', () => {
it('parses a preference list', () => {
assert.deepEqual(parseAlgorithmList('5,6,7'), [5, 6, 7])
assert.deepEqual(parseAlgorithmList('5'), [5])
})
it('picks MD5 when offered', () => {
assert.equal(selectAlgorithm([1, CHAP_ALGORITHM_MD5, 6]), CHAP_ALGORITHM_MD5)
})
it('returns undefined when MD5 is not offered (never falls back)', () => {
assert.equal(selectAlgorithm([1, 2]), undefined)
})
})
describe('verifyResponse', () => {
const secret = 's3cr3t'
const challenge = generateChallenge()
const id = generateId()
const good = computeResponse(id, secret, challenge)
it('accepts a matching response', () => {
assert.equal(verifyResponse(good, computeResponse(id, secret, challenge)), true)
})
it('rejects a wrong secret', () => {
assert.equal(verifyResponse(good, computeResponse(id, 'wrong', challenge)), false)
})
it('rejects a wrong-length response without throwing', () => {
assert.equal(verifyResponse(good, Buffer.alloc(4)), false)
})
})

141
@vates/iscsi/src/cli.mts Executable file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env node
import { stat, truncate, writeFile } from 'node:fs/promises'
import { basename } from 'node:path'
import { parseArgs } from 'node:util'
import { FileBlockDevice, IscsiTarget } from './index.mjs'
const USAGE = `Usage: vates-iscsi <file> [options]
Serve a single read/write iSCSI LUN backed by <file>.
Arguments:
<file> Backing file. Must exist and have a size that is a
multiple of the block size, unless --size is given.
Options:
--serial <serial> LUN serial number (default: derived from the IQN).
--size <bytes> Create/grow the backing file to this many bytes.
Accepts suffixes K, M, G, T (e.g. 10G).
--iqn <iqn> Target IQN (default: iqn.2024-01.tech.vates:<file>).
--host <host> Listen address (default: 0.0.0.0).
--port <port> Listen port (default: 3260).
--block-size <bytes> Logical block size (default: 512).
-h, --help Show this help.
`
function fail(message: string): never {
process.stderr.write(`vates-iscsi: ${message}\n`)
process.exit(1)
}
/** Parse a positive integer with optional K/M/G/T suffix (powers of 1024). */
function parseSize(value: string, label: string): number {
const match = /^(\d+)([KMGT]?)$/i.exec(value.trim())
if (match === null) {
fail(`invalid ${label}: ${value}`)
}
const units: Record<string, number> = { '': 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 }
return Number(match[1]) * units[match[2].toUpperCase()]
}
/** Sanitize a string into the characters allowed in the IQN-specific part. */
function sanitizeIqnPart(value: string): string {
const cleaned = value.toLowerCase().replace(/[^a-z0-9.-]+/g, '-').replace(/^-+|-+$/g, '')
return cleaned.length > 0 ? cleaned : 'lun0'
}
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
serial: { type: 'string' },
size: { type: 'string' },
iqn: { type: 'string' },
host: { type: 'string' },
port: { type: 'string' },
'block-size': { type: 'string' },
help: { type: 'boolean', short: 'h' },
},
})
if (values.help || positionals.length === 0) {
process.stdout.write(USAGE)
process.exit(values.help ? 0 : 1)
}
if (positionals.length > 1) {
fail(`expected a single <file>, got ${positionals.length}`)
}
const path = positionals[0]
const blockSize = values['block-size'] !== undefined ? parseSize(values['block-size'], 'block size') : 512
const port = values.port !== undefined ? Number.parseInt(values.port, 10) : 3260
if (!Number.isInteger(port) || port < 0 || port > 65535) {
fail(`invalid port: ${values.port}`)
}
// Create/grow the backing file when --size is requested.
if (values.size !== undefined) {
const size = parseSize(values.size, 'size')
if (size % blockSize !== 0) {
fail(`--size (${size}) must be a multiple of the block size (${blockSize})`)
}
const exists = await stat(path).then(
() => true,
() => false
)
if (!exists) {
await writeFile(path, Buffer.alloc(0))
}
await truncate(path, size)
}
// Validate the backing file before binding the socket.
const stats = await stat(path).catch(() => fail(`backing file not found: ${path} (create one, e.g. \`truncate -s 10G ${path}\`, or pass --size)`))
if (stats.size === 0) {
fail(`backing file is empty: ${path} (size it first, e.g. \`truncate -s 10G ${path}\`, or pass --size)`)
}
const iqn = values.iqn ?? `iqn.2024-01.tech.vates:${sanitizeIqnPart(basename(path).replace(/\.[^.]+$/, ''))}`
const target = new IscsiTarget({
iqn,
host: values.host,
port,
lun: new FileBlockDevice({ path, blockSize }),
identity: values.serial !== undefined ? { serial: values.serial } : undefined,
})
await target.listen()
const address = target.address()
const listenHost = values.host ?? '0.0.0.0'
const listenPort = address?.port ?? port
process.stdout.write(
[
`iSCSI target listening on ${listenHost}:${listenPort}`,
` IQN: ${iqn}`,
` LUN: ${path} (${stats.size} bytes, ${blockSize}-byte blocks)`,
` serial: ${values.serial ?? iqn}`,
``,
`Discover with: iscsiadm -m discovery -t sendtargets -p ${listenHost === '0.0.0.0' ? '127.0.0.1' : listenHost}:${listenPort}`,
`Press Ctrl-C to stop.`,
``,
].join('\n')
)
let stopping = false
function stop(): void {
if (stopping) {
return
}
stopping = true
target.close().then(
() => process.exit(0),
error => {
process.stderr.write(`vates-iscsi: error on shutdown: ${String(error)}\n`)
process.exit(1)
}
)
}
process.once('SIGINT', stop)
process.once('SIGTERM', stop)

View File

@@ -0,0 +1,622 @@
import type { Socket } from 'node:net'
import { createLogger, type Logger } from '@xen-orchestra/log'
import type { BlockDevice } from './backend.mjs'
import {
Asc,
DATA_IN_FLAG_FINAL,
DATA_IN_FLAG_OVERFLOW,
DATA_IN_FLAG_STATUS,
DATA_IN_FLAG_UNDERFLOW,
FLAG_FINAL,
InitiatorOpcode,
ISCSI_VERSION,
LoginStatusClass,
LogoutResponse,
RejectReason,
RESERVED_TAG,
SCSI_RESP_FLAG_OVERFLOW,
SCSI_RESP_FLAG_UNDERFLOW,
SCSI_RESPONSE_COMMAND_COMPLETED,
ScsiStatus,
SenseKey,
TargetOpcode,
TEXT_FLAG_FINAL,
} from './constants.mjs'
import { buildFixedSense, handleScsiCommand, type ScsiIdentity } from './scsi.mjs'
import {
buildSendTargetsResponse,
LOGIN_MAX_DATA_SEGMENT_LENGTH,
LoginNegotiator,
OUR_MAX_RECV_DATA_SEGMENT_LENGTH,
parseTextKeys,
} from './login.mjs'
import { allocBhs, assemblePdu, type IncomingPdu, readPdu, writePdu } from './pdu.mjs'
import {
decodeCdb,
type ChapCredentials,
type CommandContext,
type NegotiatedParams,
type ScsiResponseOptions,
type SessionType,
} from './types.mjs'
import { buildR2t, receiveDataOut, type PendingWrite } from './writePath.mjs'
import { Semaphore } from './semaphore.mjs'
const log: Logger = createLogger('vates:iscsi')
/** 2^32: the modulus for all iSCSI sequence-number arithmetic. */
export const SERIAL_MODULO = 0x1_0000_0000
/** Increment an iSCSI serial number with 32-bit wraparound. */
export function incrementSerial(value: number): number {
return (value + 1) % SERIAL_MODULO
}
/** Add `delta` to an iSCSI serial number with 32-bit wraparound. */
export function addSerial(value: number, delta: number): number {
return (value + delta) % SERIAL_MODULO
}
/**
* Initiator opcodes whose BHS bytes 24-27 are CmdSN. The two that are missing —
* SCSI Data-Out and SNACK — have those bytes reserved instead; see the comment
* on `Connection`'s `#updateCommandWindow` for what reading them would cost.
*/
const CARRIES_CMD_SN: ReadonlySet<number> = new Set([
InitiatorOpcode.NOP_OUT,
InitiatorOpcode.SCSI_COMMAND,
InitiatorOpcode.SCSI_TASK_MGMT_REQUEST,
InitiatorOpcode.LOGIN_REQUEST,
InitiatorOpcode.TEXT_REQUEST,
InitiatorOpcode.LOGOUT_REQUEST,
])
/** Everything a {@link Connection} needs from the owning target. */
export interface ConnectionDeps {
readonly iqn: string
readonly identity: ScsiIdentity
readonly lun: BlockDevice
/** Drain timeout for outbound PDUs, in ms (0 disables). */
readonly writeTimeoutMs: number
/** Size of the CmdSN command window advertised to the initiator. */
readonly cmdWindow: number
/** Max number of READs served concurrently (see {@link Connection}). */
readonly readConcurrency: number
/** Allocate a fresh, non-zero Target Session Identifying Handle. */
allocateTsih(): number
/** When set, require one-way CHAP: the target challenges and verifies the initiator. */
readonly chap?: ChapCredentials
/**
* Time allowed to establish a Normal session, in ms (0 or unset disables):
* past it, the connection is closed. Also bounds Discovery sessions, which
* never become Normal ones.
*/
readonly loginTimeoutMs?: number
/** Called once this connection has established a Normal session. */
onSessionEstablished?(connection: Connection): void
/** Max length of a single READ or WRITE, in bytes. */
readonly maxTransferLength?: number
}
type Phase = 'login' | 'fullFeature' | 'closed'
/**
* Drives one iSCSI connection: login, command-window/StatSN sequencing, and
* full-feature-phase PDU dispatch. With MaxConnections=1 a connection is also
* the whole session.
*
* Everything but READ runs one at a time; the read loop advances only once a
* command fully completes. READ is dispatched without waiting on its own
* `lun.read()` (can be a slow network fetch) — up to `readConcurrency` at
* once, via `#readGate` — so one slow read can't block every other command.
*
* StatSN is allocated and written to the socket synchronously, with no
* `await` in between, so responses reach the wire in whatever order the event
* loop runs their completions — no extra sequencing needed. This does not
* order a READ against a concurrent WRITE to the same LBA range: it may see
* old or new bytes, like an untagged SCSI command with no ordering barrier.
*/
export class Connection implements CommandContext {
readonly #socket: Socket
readonly #deps: ConnectionDeps
readonly #login: LoginNegotiator
readonly #readGate: Semaphore
#phase: Phase = 'login'
#sessionType: SessionType = 'Normal'
#params: NegotiatedParams = { initiatorMaxRecvDataSegmentLength: 8192, maxBurstLength: 262144 }
#statSN = 0
#expCmdSN = 0
#maxCmdSN = 0
#cmdSNInitialized = false
#tsih = 0
// In-progress WRITEs keyed by Initiator Task Tag. Data-Out PDUs are routed
// here, so writes can be interleaved with (and outstanding alongside) other
// commands without blocking the read loop.
readonly #pendingWrites = new Map<number, PendingWrite>()
#nextTargetTransferTag = 1
#loginTimer?: ReturnType<typeof setTimeout>
constructor(socket: Socket, deps: ConnectionDeps) {
this.#socket = socket
this.#deps = deps
this.#login = new LoginNegotiator(deps.chap)
this.#readGate = new Semaphore(deps.readConcurrency)
}
get lun(): BlockDevice {
return this.#deps.lun
}
/** Forcibly tear down the connection (used when the target is closing). */
destroy(): void {
this.#phase = 'closed'
this.#socket.destroy()
}
/** Read and service PDUs until the peer logs out or the connection drops. */
async serve(): Promise<void> {
const { loginTimeoutMs = 0 } = this.#deps
if (loginTimeoutMs > 0) {
this.#loginTimer = setTimeout(() => {
log.warn('no session established in time, closing the connection', { remote: this.#socket.remoteAddress })
this.destroy()
}, loginTimeoutMs).unref()
}
try {
for (;;) {
const pdu = await readPdu(
this.#socket,
this.#phase === 'login' ? LOGIN_MAX_DATA_SEGMENT_LENGTH : OUR_MAX_RECV_DATA_SEGMENT_LENGTH
)
if (pdu === null) {
break // peer closed the connection
}
const finished = await this.#dispatch(pdu)
if (finished) {
break
}
}
} catch (error) {
this.#fail(error)
} finally {
clearTimeout(this.#loginTimer)
this.#phase = 'closed'
this.#socket.destroy()
}
}
/**
* Log a connection-ending error and tear the socket down — the same outcome
* an uncaught error in the main `serve()` loop already had, now also reached
* by a concurrently-dispatched READ that fails (e.g. a backend read error):
* with nothing else awaiting it directly, it would otherwise surface only as
* an unhandled rejection while the initiator hangs waiting for a response
* that will never come. Safe to call more than once — `socket.destroy()` is
* itself idempotent.
*/
#fail(error: unknown): void {
if (this.#phase === 'closed') {
return
}
const err = error instanceof Error ? error : new Error(String(error))
const code = (err as NodeJS.ErrnoException).code
// A peer dropping the socket (e.g. on logout) is normal, not a fault.
if (code === 'ECONNRESET' || code === 'EPIPE' || code === 'ECONNABORTED') {
log.debug('connection closed by peer', { code })
} else {
log.warn('connection error', err)
}
this.#phase = 'closed'
this.#socket.destroy()
}
// --- sequencing -----------------------------------------------------------
/**
* Advance the command window from a freshly received PDU — but only from one
* that actually carries a CmdSN.
*
* BHS bytes 24-27 hold CmdSN on command PDUs only; on a SCSI Data-Out (RFC
* 7143 §11.7) and a SNACK they are reserved, as is the immediate bit. An
* initiator zeroes them, so treating such a PDU as a command reads CmdSN=0,
* takes the non-immediate branch, and rewinds the window to ExpCmdSN=1 /
* MaxCmdSN=1+window — then advertises it on the very next R2T, SCSI Response,
* or concurrent READ's Data-In. Initiators discard the rewind as stale only
* while the session's CmdSN stays below 2^31; past that it compares as newer
* under serial arithmetic, is adopted, and the window collapses into a stall.
*
* Since every WRITE is R2T-solicited (InitialR2T=Yes), Data-Out is the common
* case, not an edge one. Allowlisting the opcodes that do carry CmdSN, rather
* than skipping the two that don't, keeps a newly handled opcode from
* silently inheriting the bug.
*/
#updateCommandWindow(pdu: IncomingPdu): void {
if (!CARRIES_CMD_SN.has(pdu.opcode)) {
return
}
const cmdSN = pdu.cmdSN
if (!this.#cmdSNInitialized) {
this.#expCmdSN = cmdSN
this.#cmdSNInitialized = true
}
// Immediate PDUs (Login, NOP pings, aborts) do not consume a CmdSN slot.
if (!pdu.immediate) {
this.#expCmdSN = incrementSerial(cmdSN)
}
this.#maxCmdSN = addSerial(this.#expCmdSN, this.#deps.cmdWindow)
}
/** Current StatSN without advancing (for R2T, NOP-In, Reject, non-final Data-In). */
#currentStatSN(): number {
return this.#statSN
}
/** Return the current StatSN and advance it (for status-bearing responses). */
#nextStatSN(): number {
const value = this.#statSN
this.#statSN = incrementSerial(value)
return value
}
#send(buffer: Buffer): Promise<void> {
return writePdu(this.#socket, buffer, this.#deps.writeTimeoutMs)
}
// --- dispatch -------------------------------------------------------------
/** Returns true when the connection should be closed (after a logout). */
async #dispatch(pdu: IncomingPdu): Promise<boolean> {
this.#updateCommandWindow(pdu)
if (this.#phase === 'login') {
if (pdu.opcode === InitiatorOpcode.LOGIN_REQUEST) {
return await this.#handleLogin(pdu)
}
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
return false
}
switch (pdu.opcode) {
case InitiatorOpcode.SCSI_COMMAND:
if (this.#sessionType === 'Discovery') {
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
} else {
await this.#handleScsiCommand(pdu)
}
return false
case InitiatorOpcode.TEXT_REQUEST:
await this.#handleText(pdu)
return false
case InitiatorOpcode.NOP_OUT:
await this.#handleNopOut(pdu)
return false
case InitiatorOpcode.LOGOUT_REQUEST:
await this.#handleLogout(pdu)
return true
case InitiatorOpcode.SCSI_DATA_OUT:
await this.#handleDataOut(pdu)
return false
default:
await this.#sendReject(pdu, RejectReason.COMMAND_NOT_SUPPORTED)
return false
}
}
/** Returns true when the login failed and the connection must be closed. */
async #handleLogin(pdu: IncomingPdu): Promise<boolean> {
const { flagsByte, data, statusClass = LoginStatusClass.SUCCESS, statusDetail = 0 } = this.#login.process(pdu)
if (this.#login.complete && this.#tsih === 0) {
this.#tsih = this.#deps.allocateTsih()
}
const bhs = allocBhs(TargetOpcode.LOGIN_RESPONSE)
bhs[1] = flagsByte
bhs[2] = ISCSI_VERSION // Version-max
bhs[3] = ISCSI_VERSION // Version-active
pdu.bhs.copy(bhs, 8, 8, 14) // echo ISID (6 bytes)
bhs.writeUInt16BE(this.#tsih, 14)
bhs.writeUInt32BE(pdu.itt, 16)
bhs.writeUInt32BE(this.#nextStatSN(), 24)
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
bhs[36] = statusClass
bhs[37] = statusDetail
await this.#send(assemblePdu(bhs, data))
// destroyed while the response was being written (login timeout, target
// closing): the write still succeeds, but this connection must not go on
// and replace the current session
if (this.#phase === 'closed') {
return true
}
if (statusClass !== LoginStatusClass.SUCCESS) {
// RFC 7143 §6.3: the failure response goes out first (above), then the
// target closes the connection.
log.warn('login rejected', { statusClass, statusDetail, initiator: this.#login.initiatorName })
this.#phase = 'closed'
return true
}
if (this.#login.complete) {
this.#phase = 'fullFeature'
this.#sessionType = this.#login.sessionType
this.#params = this.#login.params
log.info('session established', {
initiator: this.#login.initiatorName,
sessionType: this.#sessionType,
target: this.#login.targetName,
})
if (this.#sessionType === 'Normal') {
clearTimeout(this.#loginTimer)
this.#deps.onSessionEstablished?.(this)
}
}
return false
}
async #handleText(pdu: IncomingPdu): Promise<void> {
const keys = parseTextKeys(pdu.data)
// SendTargets discovery: advertise this target at the address the initiator
// actually connected to.
const data =
keys.get('SendTargets') !== undefined
? buildSendTargetsResponse(
this.#deps.iqn,
this.#socket.localAddress ?? '0.0.0.0',
this.#socket.localPort ?? 3260
)
: Buffer.alloc(0)
const bhs = allocBhs(TargetOpcode.TEXT_RESPONSE)
bhs[1] = TEXT_FLAG_FINAL
bhs.writeUInt32BE(pdu.itt, 16)
bhs.writeUInt32BE(RESERVED_TAG, 20) // Target Transfer Tag: none / final
bhs.writeUInt32BE(this.#nextStatSN(), 24)
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
await this.#send(assemblePdu(bhs, data))
}
async #handleNopOut(pdu: IncomingPdu): Promise<void> {
// A NOP-Out with the reserved ITT is the initiator's reply to a NOP-In we
// sent; nothing to do. Otherwise echo it back as a NOP-In ping reply.
if (pdu.itt === RESERVED_TAG) {
return
}
const bhs = allocBhs(TargetOpcode.NOP_IN)
bhs[1] = FLAG_FINAL
pdu.bhs.copy(bhs, 8, 8, 16) // echo LUN
bhs.writeUInt32BE(pdu.itt, 16)
bhs.writeUInt32BE(RESERVED_TAG, 20) // Target Transfer Tag
bhs.writeUInt32BE(this.#currentStatSN(), 24) // NOP-In does not advance StatSN
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
await this.#send(assemblePdu(bhs, pdu.data)) // echo ping data
}
async #handleLogout(pdu: IncomingPdu): Promise<void> {
const bhs = allocBhs(TargetOpcode.LOGOUT_RESPONSE)
bhs[1] = FLAG_FINAL
bhs[2] = LogoutResponse.SUCCESS
bhs.writeUInt32BE(pdu.itt, 16)
bhs.writeUInt32BE(this.#nextStatSN(), 24)
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
await this.#send(assemblePdu(bhs))
}
async #handleScsiCommand(pdu: IncomingPdu): Promise<void> {
// Login declares ImmediateData=No unconditionally, so a data segment here is
// write data we never solicited and have no path to apply. Ignoring it would
// acknowledge a write missing its first bytes — exactly the silent loss the
// solicited path is built to avoid — so fail the command loudly instead and
// let the initiator retry or give up.
if (pdu.data.length > 0) {
log.warn('immediate data is not supported', { itt: pdu.itt, length: pdu.data.length })
const sense = buildFixedSense({ key: SenseKey.ILLEGAL_REQUEST, ...Asc.INVALID_FIELD_IN_CDB })
await this.sendScsiResponse(pdu.itt, ScsiStatus.CHECK_CONDITION, { sense })
return
}
const cdb = pdu.bhs.subarray(32, 48)
if (decodeCdb(cdb).kind === 'read') {
// Not awaited: the read loop must move on immediately rather than block on
// this `lun.read()`. See the class doc for why no extra ordering is needed.
void this.#readGate
.run(() => handleScsiCommand(cdb, pdu.itt, this, this.#deps.identity, this.#deps.maxTransferLength))
.catch(error => {
this.#fail(error)
})
return
}
await handleScsiCommand(cdb, pdu.itt, this, this.#deps.identity, this.#deps.maxTransferLength)
}
async #sendReject(pdu: IncomingPdu, reason: number): Promise<void> {
log.debug('rejecting PDU', { opcode: pdu.opcode, itt: pdu.itt, reason, phase: this.#phase })
const bhs = allocBhs(TargetOpcode.REJECT)
bhs[1] = FLAG_FINAL
bhs[2] = reason
bhs.writeUInt32BE(RESERVED_TAG, 16)
bhs.writeUInt32BE(this.#currentStatSN(), 24) // Reject snapshots StatSN
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
// The rejected PDU's header is returned as the data segment.
await this.#send(assemblePdu(bhs, pdu.bhs))
}
// --- CommandContext implementation ----------------------------------------
async sendReadData(itt: number, data: Buffer, allocationLength: number): Promise<void> {
let payload = data
let residualOverflow = 0
let residualUnderflow = 0
if (data.length > allocationLength) {
payload = data.subarray(0, allocationLength)
residualOverflow = data.length - allocationLength
} else if (data.length < allocationLength) {
residualUnderflow = allocationLength - data.length
}
if (payload.length === 0) {
return this.sendScsiResponse(itt, ScsiStatus.GOOD, { residualOverflow, residualUnderflow })
}
const maxSegment = this.#params.initiatorMaxRecvDataSegmentLength
let offset = 0
let dataSN = 0
while (offset < payload.length) {
const end = Math.min(offset + maxSegment, payload.length)
const chunk = payload.subarray(offset, end)
const isLast = end >= payload.length
const bhs = allocBhs(TargetOpcode.SCSI_DATA_IN)
let flags = 0
if (isLast) {
// Piggyback GOOD status on the final Data-In PDU.
flags |= DATA_IN_FLAG_FINAL | DATA_IN_FLAG_STATUS
if (residualOverflow > 0) {
flags |= DATA_IN_FLAG_OVERFLOW
}
if (residualUnderflow > 0) {
flags |= DATA_IN_FLAG_UNDERFLOW
}
bhs[3] = ScsiStatus.GOOD // Status valid only when the S bit is set
}
bhs[1] = flags
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(RESERVED_TAG, 20) // Target Transfer Tag
if (isLast) {
bhs.writeUInt32BE(this.#nextStatSN(), 24) // StatSN valid only with the S bit
}
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
bhs.writeUInt32BE(dataSN, 36)
bhs.writeUInt32BE(offset, 40) // Buffer Offset
if (isLast) {
bhs.writeUInt32BE(residualOverflow || residualUnderflow, 44)
}
await this.#send(assemblePdu(bhs, chunk))
offset = end
dataSN++
}
}
async beginWrite(itt: number, lunOffset: number, totalLength: number): Promise<void> {
const pending: PendingWrite = {
itt,
targetTransferTag: this.#nextTargetTransferTag++,
lunOffset,
// zero-filled, not `allocUnsafe`: belt to `receiveDataOut`'s braces, so a
// buffer that somehow reaches the LUN partly filled writes zeroes rather
// than recycled heap (which the initiator could then read straight back)
buffer: Buffer.alloc(totalLength),
totalLength,
contiguousReceived: 0,
solicited: 0,
r2tSN: 0,
}
this.#pendingWrites.set(itt, pending)
log.debug('write begin', { itt, lunOffset, totalLength })
await this.#sendNextR2t(pending)
}
/** Solicit the next burst (≤ MaxBurstLength) of a pending write via an R2T. */
async #sendNextR2t(pending: PendingWrite): Promise<void> {
const desiredLength = Math.min(pending.totalLength - pending.solicited, this.#params.maxBurstLength)
const bufferOffset = pending.solicited
const r2tSN = pending.r2tSN
pending.solicited += desiredLength
pending.r2tSN++
await this.#send(
buildR2t(
{ itt: pending.itt, r2tSN, targetTransferTag: pending.targetTransferTag, bufferOffset, desiredLength },
{ statSN: this.#currentStatSN(), expCmdSN: this.#expCmdSN, maxCmdSN: this.#maxCmdSN }
)
)
}
async #handleDataOut(pdu: IncomingPdu): Promise<void> {
const pending = this.#pendingWrites.get(pdu.itt)
if (pending === undefined) {
// Data-Out for an unknown command: protocol desync.
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
return
}
const bufferOffset = pdu.readU32(40) // BufferOffset is relative to the command's data
const outcome = receiveDataOut(pending, {
targetTransferTag: pdu.readU32(20),
bufferOffset,
data: pdu.data,
})
log.debug('write data-out', {
itt: pending.itt,
offset: bufferOffset,
length: pdu.data.length,
received: pending.contiguousReceived,
total: pending.totalLength,
})
if (!outcome.ok) {
// The command is dropped, not just the PDU: leaving it pending would hang
// the initiator on an R2T that never comes, until its command timeout.
this.#pendingWrites.delete(pdu.itt)
log.warn('unplaceable write data-out', { itt: pending.itt, reason: outcome.reason })
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
return
}
if (outcome.complete) {
this.#pendingWrites.delete(pdu.itt)
try {
await this.lun.write(pending.lunOffset, pending.buffer)
} catch (error) {
log.warn('LUN write failed', error instanceof Error ? error : new Error(String(error)))
const sense = buildFixedSense({ key: SenseKey.MEDIUM_ERROR, ...Asc.NO_ADDITIONAL_SENSE })
await this.sendScsiResponse(pending.itt, ScsiStatus.CHECK_CONDITION, { sense })
return
}
await this.sendScsiResponse(pending.itt, ScsiStatus.GOOD)
} else if (outcome.burstComplete) {
// Current burst complete and data remains: solicit the next one.
await this.#sendNextR2t(pending)
}
}
async sendScsiResponse(itt: number, status: number, options: ScsiResponseOptions = {}): Promise<void> {
const { sense, residualOverflow = 0, residualUnderflow = 0 } = options
const bhs = allocBhs(TargetOpcode.SCSI_RESPONSE)
let flags = FLAG_FINAL // bit 7 is always set on a SCSI Response
if (residualOverflow > 0) {
flags |= SCSI_RESP_FLAG_OVERFLOW
}
if (residualUnderflow > 0) {
flags |= SCSI_RESP_FLAG_UNDERFLOW
}
bhs[1] = flags
bhs[2] = SCSI_RESPONSE_COMMAND_COMPLETED
bhs[3] = status
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(this.#nextStatSN(), 24)
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
bhs.writeUInt32BE(residualOverflow || residualUnderflow, 44)
let data: Buffer | undefined
if (sense !== undefined) {
// Sense data in a SCSI Response is prefixed with a 2-byte length.
data = Buffer.alloc(2 + sense.length)
data.writeUInt16BE(sense.length, 0)
sense.copy(data, 2)
}
await this.#send(assemblePdu(bhs, data))
}
}

View File

@@ -0,0 +1,712 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import type { Socket } from 'node:net'
import { Duplex } from 'node:stream'
import type { BlockDevice } from './backend.mjs'
import {
DATA_IN_FLAG_FINAL,
DATA_IN_FLAG_OVERFLOW,
DATA_IN_FLAG_STATUS,
DATA_IN_FLAG_UNDERFLOW,
FLAG_FINAL,
InitiatorOpcode,
LOGIN_CSG_SHIFT,
LOGIN_FLAG_TRANSIT,
LoginStage,
LoginStatusClass,
OPCODE_IMMEDIATE,
RejectReason,
RESERVED_TAG,
ScsiStatus,
SenseKey,
TargetOpcode,
} from './constants.mjs'
import { Connection, type ConnectionDeps } from './connection.mjs'
import { serializeTextKeys } from './login.mjs'
import { assemblePdu, IncomingPdu } from './pdu.mjs'
import type { ScsiIdentity } from './scsi.mjs'
const IDENTITY: ScsiIdentity = { vendor: 'VATES', product: 'ISCSI LUN', revision: '0001', serial: 'unit-serial-1' }
const BLOCK_SIZE = 512
const LUN_SIZE = 16 * BLOCK_SIZE
// Small enough that a handful of blocks splits into several PDUs / bursts, so
// the chunking and multi-burst paths are exercised without huge buffers.
const MAX_RECV_DATA_SEGMENT_LENGTH = 512
const MAX_BURST_LENGTH = 512
// --- test doubles -----------------------------------------------------------
/**
* Stands in for the TCP socket. `deliver()` queues a PDU for the connection's
* read loop (the readable side); everything the connection writes is captured
* in `sent` rather than transmitted. `Connection` only ever reads, writes, and
* destroys its socket, plus `localAddress`/`localPort` for SendTargets — hence
* the narrow cast in `asSocket()`.
*/
class FakeSocket extends Duplex {
readonly sent: Array<Buffer> = []
readonly localAddress = '10.0.0.1'
readonly localPort = 3260
override _read(): void {}
override _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
this.sent.push(Buffer.from(chunk))
callback()
}
/** Hand a PDU to the connection. */
deliver(pdu: Buffer): void {
this.push(pdu)
}
/** Clean end-of-stream, which ends `serve()`'s read loop. */
finish(): void {
this.push(null)
}
asSocket(): Socket {
return this as unknown as Socket
}
}
class MemoryLun implements BlockDevice {
readonly content = Buffer.alloc(LUN_SIZE)
/** Set to make the next `write()` fail, standing in for a backend error. */
failNextWrite = false
getSize(): number {
return this.content.length
}
getBlockSize(): number {
return BLOCK_SIZE
}
async read(offset: number, length: number): Promise<Buffer> {
return Buffer.from(this.content.subarray(offset, offset + length))
}
async write(offset: number, data: Buffer): Promise<void> {
if (this.failNextWrite) {
this.failNextWrite = false
throw new Error('backend write failed')
}
data.copy(this.content, offset)
}
async flush(): Promise<void> {}
async close(): Promise<void> {}
}
// --- initiator-side PDU builders --------------------------------------------
/** A single-PDU operational login that transits straight to Full Feature Phase. */
const loginPdu = (): Buffer => {
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.LOGIN_REQUEST | OPCODE_IMMEDIATE
bhs[1] = LOGIN_FLAG_TRANSIT | (LoginStage.OPERATIONAL_NEGOTIATION << LOGIN_CSG_SHIFT) | LoginStage.FULL_FEATURE_PHASE
bhs.writeUInt32BE(1, 16) // ITT
return assemblePdu(
bhs,
serializeTextKeys([
['InitiatorName', 'iqn.2026-01.tech.vates:test-initiator'],
['SessionType', 'Normal'],
['HeaderDigest', 'None'],
['DataDigest', 'None'],
['MaxRecvDataSegmentLength', String(MAX_RECV_DATA_SEGMENT_LENGTH)],
['MaxBurstLength', String(MAX_BURST_LENGTH)],
])
)
}
const scsiCommandPdu = ({
itt,
cmdSN,
cdb,
data,
}: {
itt: number
cmdSN: number
cdb: Buffer
data?: Buffer
}): Buffer => {
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.SCSI_COMMAND
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(cmdSN, 24)
cdb.copy(bhs, 32)
return assemblePdu(bhs, data)
}
const dataOutPdu = ({
itt,
targetTransferTag,
bufferOffset,
data,
dataSN = 0,
}: {
itt: number
targetTransferTag: number
bufferOffset: number
data: Buffer
dataSN?: number
}): Buffer => {
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.SCSI_DATA_OUT
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(targetTransferTag, 20)
bhs.writeUInt32BE(dataSN, 36)
bhs.writeUInt32BE(bufferOffset, 40)
return assemblePdu(bhs, data)
}
const cdb10 = (opcode: number, lba: number, blocks: number): Buffer => {
const cdb = Buffer.alloc(16)
cdb[0] = opcode
cdb.writeUInt32BE(lba, 2)
cdb.writeUInt16BE(blocks, 7)
return cdb
}
const read10 = (lba: number, blocks: number) => cdb10(0x28, lba, blocks)
const write10 = (lba: number, blocks: number) => cdb10(0x2a, lba, blocks)
const cdb16 = (opcode: number, lba: bigint, blocks: number): Buffer => {
const cdb = Buffer.alloc(16)
cdb[0] = opcode
cdb.writeBigUInt64BE(lba, 2)
cdb.writeUInt32BE(blocks, 10)
return cdb
}
const read16 = (lba: bigint, blocks: number) => cdb16(0x88, lba, blocks)
const write16 = (lba: bigint, blocks: number) => cdb16(0x8a, lba, blocks)
const inquiry = (allocationLength: number): Buffer => {
const cdb = Buffer.alloc(16)
cdb[0] = 0x12
cdb.writeUInt16BE(allocationLength, 3)
return cdb
}
// --- harness ----------------------------------------------------------------
/** Re-read a PDU the target sent, so assertions can talk in field names. */
const parseSent = (buffer: Buffer): IncomingPdu => {
const dataSegmentLength = buffer.readUIntBE(5, 3)
return new IncomingPdu(buffer.subarray(0, 48), Buffer.alloc(0), buffer.subarray(48, 48 + dataSegmentLength))
}
/**
* Sense key of a SCSI Response: its data segment is a 2-byte sense length then
* the fixed-format sense data, whose byte 2 holds the key in its low nibble.
*/
const senseKey = (response: IncomingPdu): number => response.data[4] & 0x0f
class Harness {
readonly socket = new FakeSocket()
readonly lun = new MemoryLun()
readonly serving: Promise<void>
constructor(overrides: Partial<ConnectionDeps> = {}) {
const deps: ConnectionDeps = {
iqn: 'iqn.2026-01.tech.vates:target',
identity: IDENTITY,
lun: this.lun,
writeTimeoutMs: 0,
cmdWindow: 64,
readConcurrency: 4,
allocateTsih: () => 1,
...overrides,
}
this.serving = new Connection(this.socket.asSocket(), deps).serve()
}
/** Every PDU the target has sent, parsed. */
get sent(): Array<IncomingPdu> {
return this.socket.sent.map(parseSent)
}
/**
* Let the read loop run until the target has sent `count` PDUs in total,
* optionally delivering something first. Bounded so a response that never
* comes fails the test instead of hanging it.
*/
async untilSent(count: number, deliver?: () => void): Promise<Array<IncomingPdu>> {
deliver?.()
for (let turn = 0; turn < 500 && this.socket.sent.length < count; turn++) {
await new Promise(resolve => setImmediate(resolve))
}
const sent = this.sent
assert.ok(
sent.length >= count,
`expected ${count} PDUs, got ${sent.length}: ${sent.map(pdu => pdu.opcode.toString(16)).join(',')}`
)
return sent
}
/** Log in and confirm the target accepted it. */
async login(): Promise<void> {
// relative to what has already been sent, so this works mid-connection too
const baseline = this.socket.sent.length
const response = (await this.untilSent(baseline + 1, () => this.socket.deliver(loginPdu())))[baseline]
assert.equal(response.opcode, TargetOpcode.LOGIN_RESPONSE)
assert.equal(response.readU8(36), LoginStatusClass.SUCCESS)
}
async close(): Promise<void> {
this.socket.finish()
await this.serving
}
}
/** Log in, then run `body`, always tearing the connection down afterwards. */
const withSession = async (body: (harness: Harness) => Promise<void>, overrides?: Partial<ConnectionDeps>) => {
const harness = new Harness(overrides)
try {
await harness.login()
await body(harness)
} finally {
await harness.close()
}
}
const CMD_WINDOW = 64
/** ExpCmdSN (bytes 28-31) and MaxCmdSN (32-35), the command window we advertise. */
const commandWindow = (pdu: IncomingPdu) => ({ expCmdSN: pdu.readU32(28), maxCmdSN: pdu.readU32(32) })
describe('command window', () => {
// Bytes 24-27 are CmdSN only on command PDUs. On a Data-Out (RFC 7143 §11.7)
// and a SNACK they are reserved, so reading a window out of them yields the
// zeroes an initiator puts there — advertising ExpCmdSN=1 mid-session. Real
// initiators discard that as stale only while session CmdSN < 2^31; past the
// wrap it compares as newer, is adopted, and the window collapses to a stall.
it('is not recomputed from the reserved bytes of a Data-Out', async () => {
await withSession(async harness => {
// two bursts, so an R2T is emitted *after* a Data-Out has been processed
harness.socket.deliver(scsiCommandPdu({ itt: 31, cmdSN: 50, cdb: write10(0, 2) }))
const [, firstR2t] = await harness.untilSent(2)
const expected = { expCmdSN: 51, maxCmdSN: 51 + CMD_WINDOW }
assert.deepEqual(commandWindow(firstR2t), expected)
const ttt = firstR2t.readU32(20)
harness.socket.deliver(
dataOutPdu({ itt: 31, targetTransferTag: ttt, bufferOffset: 0, data: Buffer.alloc(BLOCK_SIZE, 0x11) })
)
const [, , secondR2t] = await harness.untilSent(3)
assert.equal(secondR2t.opcode, TargetOpcode.R2T)
assert.deepEqual(commandWindow(secondR2t), expected)
harness.socket.deliver(
dataOutPdu({
itt: 31,
targetTransferTag: ttt,
bufferOffset: BLOCK_SIZE,
data: Buffer.alloc(BLOCK_SIZE, 0x22),
dataSN: 1,
})
)
const [, , , response] = await harness.untilSent(4)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.deepEqual(commandWindow(response), expected)
})
})
it('is not recomputed from an opcode the target does not implement', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 33, cmdSN: 70, cdb: read10(0, 1) }))
const [, dataIn] = await harness.untilSent(2)
const expected = { expCmdSN: 71, maxCmdSN: 71 + CMD_WINDOW }
assert.deepEqual(commandWindow(dataIn), expected)
// SNACK also has bytes 24-27 reserved; it is rejected, but the Reject it
// gets must still carry the window the session actually reached
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.SNACK_REQUEST
harness.socket.deliver(assemblePdu(bhs))
const [, , rejected] = await harness.untilSent(3)
assert.equal(rejected.opcode, TargetOpcode.REJECT)
assert.equal(rejected.readU8(2), RejectReason.COMMAND_NOT_SUPPORTED)
assert.deepEqual(commandWindow(rejected), expected)
})
})
it('advances the window for each command PDU, but not for an immediate one', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 35, cmdSN: 5, cdb: read10(0, 1) }))
const [, dataIn] = await harness.untilSent(2)
assert.deepEqual(commandWindow(dataIn), { expCmdSN: 6, maxCmdSN: 6 + CMD_WINDOW })
// an immediate NOP-Out is answered but consumes no CmdSN slot
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.NOP_OUT | OPCODE_IMMEDIATE
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(37, 16)
bhs.writeUInt32BE(RESERVED_TAG, 20)
bhs.writeUInt32BE(6, 24)
harness.socket.deliver(assemblePdu(bhs))
const [, , nopIn] = await harness.untilSent(3)
assert.equal(nopIn.opcode, TargetOpcode.NOP_IN)
assert.deepEqual(commandWindow(nopIn), { expCmdSN: 6, maxCmdSN: 6 + CMD_WINDOW })
})
})
})
describe('login', () => {
it('reaches full feature phase and only then accepts commands', async () => {
const harness = new Harness()
try {
// a command before login is a protocol error, not a served command
harness.socket.deliver(scsiCommandPdu({ itt: 5, cmdSN: 0, cdb: read10(0, 1) }))
const [rejected] = await harness.untilSent(1)
assert.equal(rejected.opcode, TargetOpcode.REJECT)
assert.equal(rejected.readU8(2), RejectReason.PROTOCOL_ERROR)
await harness.login()
// the same command now gets served
harness.socket.deliver(scsiCommandPdu({ itt: 5, cmdSN: 1, cdb: read10(0, 1) }))
const sent = await harness.untilSent(3)
assert.equal(sent[2].opcode, TargetOpcode.SCSI_DATA_IN)
} finally {
await harness.close()
}
})
})
describe('write path wiring', () => {
it('solicits the data, commits it to the LUN, and answers GOOD', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 9, cmdSN: 1, cdb: write10(2, 1) }))
const [, r2t] = await harness.untilSent(2)
assert.equal(r2t.opcode, TargetOpcode.R2T)
assert.equal(r2t.itt, 9)
assert.equal(r2t.readU32(36), 0) // R2TSN
assert.equal(r2t.readU32(40), 0) // BufferOffset
assert.equal(r2t.readU32(44), BLOCK_SIZE) // DesiredDataTransferLength
const payload = Buffer.alloc(BLOCK_SIZE, 0xab)
harness.socket.deliver(dataOutPdu({ itt: 9, targetTransferTag: r2t.readU32(20), bufferOffset: 0, data: payload }))
const [, , response] = await harness.untilSent(3)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.GOOD)
// ... and the bytes landed at the LBA the CDB asked for, nowhere else
assert.deepEqual(harness.lun.content.subarray(2 * BLOCK_SIZE, 3 * BLOCK_SIZE), payload)
assert.deepEqual(harness.lun.content.subarray(0, 2 * BLOCK_SIZE), Buffer.alloc(2 * BLOCK_SIZE))
assert.deepEqual(harness.lun.content.subarray(3 * BLOCK_SIZE), Buffer.alloc(LUN_SIZE - 3 * BLOCK_SIZE))
})
})
it('solicits one burst at a time for a write larger than MaxBurstLength', async () => {
await withSession(async harness => {
// 2 blocks with a 1-block burst limit: two R2Ts, second only after the first lands
harness.socket.deliver(scsiCommandPdu({ itt: 11, cmdSN: 1, cdb: write10(0, 2) }))
const [, first] = await harness.untilSent(2)
assert.equal(first.readU32(36), 0) // R2TSN
assert.equal(first.readU32(40), 0) // BufferOffset
assert.equal(first.readU32(44), MAX_BURST_LENGTH)
const ttt = first.readU32(20)
harness.socket.deliver(
dataOutPdu({ itt: 11, targetTransferTag: ttt, bufferOffset: 0, data: Buffer.alloc(BLOCK_SIZE, 0x11) })
)
const [, , second] = await harness.untilSent(3)
assert.equal(second.opcode, TargetOpcode.R2T)
assert.equal(second.readU32(36), 1) // R2TSN advanced
assert.equal(second.readU32(40), BLOCK_SIZE) // next burst picks up where the first ended
assert.equal(second.readU32(44), MAX_BURST_LENGTH)
// nothing is committed until the whole command has landed
assert.deepEqual(harness.lun.content.subarray(0, BLOCK_SIZE), Buffer.alloc(BLOCK_SIZE))
harness.socket.deliver(
dataOutPdu({
itt: 11,
targetTransferTag: ttt,
bufferOffset: BLOCK_SIZE,
data: Buffer.alloc(BLOCK_SIZE, 0x22),
dataSN: 1,
})
)
const [, , , response] = await harness.untilSent(4)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.GOOD)
assert.deepEqual(
harness.lun.content.subarray(0, 2 * BLOCK_SIZE),
Buffer.concat([Buffer.alloc(BLOCK_SIZE, 0x11), Buffer.alloc(BLOCK_SIZE, 0x22)])
)
})
})
it('rejects an unplaceable Data-Out and drops the command with it', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 13, cmdSN: 1, cdb: write10(0, 1) }))
const [, r2t] = await harness.untilSent(2)
const ttt = r2t.readU32(20)
// BufferOffset past the end of the command's data: a copy there would land
// nothing, so the write must be refused rather than acknowledged
harness.socket.deliver(
dataOutPdu({
itt: 13,
targetTransferTag: ttt,
bufferOffset: BLOCK_SIZE,
data: Buffer.alloc(BLOCK_SIZE, 0xcc),
})
)
const [, , rejected] = await harness.untilSent(3)
assert.equal(rejected.opcode, TargetOpcode.REJECT)
assert.equal(rejected.readU8(2), RejectReason.PROTOCOL_ERROR)
assert.deepEqual(harness.lun.content, Buffer.alloc(LUN_SIZE))
// the command is gone, not merely stalled: a now-valid Data-Out for it is
// an unknown ITT, and no SCSI Response ever completes it
harness.socket.deliver(
dataOutPdu({ itt: 13, targetTransferTag: ttt, bufferOffset: 0, data: Buffer.alloc(BLOCK_SIZE, 0xdd) })
)
const sent = await harness.untilSent(4)
assert.equal(sent[3].opcode, TargetOpcode.REJECT)
assert.deepEqual(harness.lun.content, Buffer.alloc(LUN_SIZE))
assert.equal(
sent.some(pdu => pdu.opcode === TargetOpcode.SCSI_RESPONSE),
false
)
})
})
it("rejects a Data-Out carrying another command's Target Transfer Tag", async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 15, cmdSN: 1, cdb: write10(0, 1) }))
const [, r2t] = await harness.untilSent(2)
harness.socket.deliver(
dataOutPdu({
itt: 15,
targetTransferTag: r2t.readU32(20) + 1,
bufferOffset: 0,
data: Buffer.alloc(BLOCK_SIZE, 0xee),
})
)
const [, , rejected] = await harness.untilSent(3)
assert.equal(rejected.opcode, TargetOpcode.REJECT)
assert.deepEqual(harness.lun.content, Buffer.alloc(LUN_SIZE))
})
})
it('rejects a Data-Out for a command it knows nothing about', async () => {
await withSession(async harness => {
harness.socket.deliver(
dataOutPdu({ itt: 404, targetTransferTag: 1, bufferOffset: 0, data: Buffer.alloc(BLOCK_SIZE, 0xff) })
)
const [, rejected] = await harness.untilSent(2)
assert.equal(rejected.opcode, TargetOpcode.REJECT)
assert.equal(rejected.readU8(2), RejectReason.PROTOCOL_ERROR)
})
})
it('reports a failed backend write as CHECK CONDITION, not a dropped connection', async () => {
await withSession(async harness => {
harness.lun.failNextWrite = true
harness.socket.deliver(scsiCommandPdu({ itt: 17, cmdSN: 1, cdb: write10(0, 1) }))
const [, r2t] = await harness.untilSent(2)
harness.socket.deliver(
dataOutPdu({
itt: 17,
targetTransferTag: r2t.readU32(20),
bufferOffset: 0,
data: Buffer.alloc(BLOCK_SIZE, 0xab),
})
)
const [, , response] = await harness.untilSent(3)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.CHECK_CONDITION)
assert.equal(senseKey(response), SenseKey.MEDIUM_ERROR)
})
})
it('fails a command carrying immediate data instead of silently dropping it', async () => {
await withSession(async harness => {
// ImmediateData=No is declared at login, so this cannot be honoured — and
// must not be ignored either, since the write would be short by these bytes
harness.socket.deliver(
scsiCommandPdu({ itt: 19, cmdSN: 1, cdb: write10(0, 1), data: Buffer.alloc(BLOCK_SIZE, 0x99) })
)
const [, response] = await harness.untilSent(2)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.CHECK_CONDITION)
assert.equal(senseKey(response), SenseKey.ILLEGAL_REQUEST)
assert.deepEqual(harness.lun.content, Buffer.alloc(LUN_SIZE))
// no R2T either: the command never started
assert.equal(
harness.sent.some(pdu => pdu.opcode === TargetOpcode.R2T),
false
)
})
})
})
describe('Data-In chunking', () => {
/** Read `blocks` blocks of a LUN pre-filled with a recognizable pattern. */
const readBack = async (harness: Harness, blocks: number): Promise<Array<IncomingPdu>> => {
for (let i = 0; i < harness.lun.content.length; i++) {
harness.lun.content[i] = (i * 7 + 3) & 0xff
}
harness.socket.deliver(scsiCommandPdu({ itt: 21, cmdSN: 1, cdb: read10(0, blocks) }))
const expected = Math.ceil((blocks * BLOCK_SIZE) / MAX_RECV_DATA_SEGMENT_LENGTH)
const sent = await harness.untilSent(1 + expected)
return sent.slice(1)
}
it('splits the payload at the initiator MaxRecvDataSegmentLength', async () => {
await withSession(async harness => {
const dataIn = await readBack(harness, 4)
assert.equal(dataIn.length, 4)
for (const pdu of dataIn) {
assert.equal(pdu.opcode, TargetOpcode.SCSI_DATA_IN)
assert.equal(pdu.data.length, MAX_RECV_DATA_SEGMENT_LENGTH)
}
// reassembled, it is exactly what the LUN holds
assert.deepEqual(Buffer.concat(dataIn.map(pdu => pdu.data)), harness.lun.content.subarray(0, 4 * BLOCK_SIZE))
})
})
it('numbers the PDUs with a gapless DataSN and stamps each BufferOffset', async () => {
await withSession(async harness => {
const dataIn = await readBack(harness, 3)
assert.deepEqual(
dataIn.map(pdu => pdu.readU32(36)),
[0, 1, 2]
)
assert.deepEqual(
dataIn.map(pdu => pdu.readU32(40)),
[0, MAX_RECV_DATA_SEGMENT_LENGTH, 2 * MAX_RECV_DATA_SEGMENT_LENGTH]
)
// Target Transfer Tag is unused on Data-In
for (const pdu of dataIn) {
assert.equal(pdu.readU32(20), RESERVED_TAG)
}
})
})
it('carries the status only on the final PDU, and advances StatSN once', async () => {
await withSession(async harness => {
const statSNAfterLogin = parseSent(harness.socket.sent[0]).readU32(24) + 1
const dataIn = await readBack(harness, 3)
for (const pdu of dataIn.slice(0, -1)) {
assert.equal(pdu.flags & DATA_IN_FLAG_FINAL, 0)
assert.equal(pdu.flags & DATA_IN_FLAG_STATUS, 0)
// both fields are defined as meaningless without the S bit, and are left
// unset rather than filled with a value an initiator might act on
assert.equal(pdu.readU8(3), 0)
assert.equal(pdu.readU32(24), 0)
}
const last = dataIn[dataIn.length - 1]
assert.equal(last.flags & DATA_IN_FLAG_FINAL, DATA_IN_FLAG_FINAL)
assert.equal(last.flags & DATA_IN_FLAG_STATUS, DATA_IN_FLAG_STATUS)
assert.equal(last.readU8(3), ScsiStatus.GOOD)
// exactly one StatSN consumed for the whole command, on its final PDU
assert.equal(last.readU32(24), statSNAfterLogin)
})
})
it('sends a single Data-In when the payload fits one segment', async () => {
await withSession(async harness => {
const dataIn = await readBack(harness, 1)
assert.equal(dataIn.length, 1)
assert.equal(
dataIn[0].flags & (DATA_IN_FLAG_FINAL | DATA_IN_FLAG_STATUS),
DATA_IN_FLAG_FINAL | DATA_IN_FLAG_STATUS
)
assert.equal(dataIn[0].readU32(36), 0)
})
})
it('flags residual overflow when the allocation length truncates the payload', async () => {
await withSession(async harness => {
// standard INQUIRY data is 36 bytes; ask for 8
harness.socket.deliver(scsiCommandPdu({ itt: 23, cmdSN: 1, cdb: inquiry(8) }))
const [, dataIn] = await harness.untilSent(2)
assert.equal(dataIn.opcode, TargetOpcode.SCSI_DATA_IN)
assert.equal(dataIn.data.length, 8)
assert.equal(dataIn.flags & DATA_IN_FLAG_OVERFLOW, DATA_IN_FLAG_OVERFLOW)
assert.equal(dataIn.flags & DATA_IN_FLAG_UNDERFLOW, 0)
assert.equal(dataIn.readU32(44), 36 - 8) // Residual Count
})
})
it('flags residual underflow when the payload is shorter than requested', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 25, cmdSN: 1, cdb: inquiry(100) }))
const [, dataIn] = await harness.untilSent(2)
assert.equal(dataIn.data.length, 36)
assert.equal(dataIn.flags & DATA_IN_FLAG_UNDERFLOW, DATA_IN_FLAG_UNDERFLOW)
assert.equal(dataIn.flags & DATA_IN_FLAG_OVERFLOW, 0)
assert.equal(dataIn.readU32(44), 100 - 36)
})
})
it('answers with status alone, no Data-In, when nothing is allocated for the data', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 27, cmdSN: 1, cdb: inquiry(0) }))
const [, response] = await harness.untilSent(2)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.GOOD)
assert.equal(
harness.sent.some(pdu => pdu.opcode === TargetOpcode.SCSI_DATA_IN),
false
)
})
})
})
describe('unservable CDBs', () => {
// An LBA above 2^53 cannot be held exactly by a JS number. Decoding it must
// not throw: the error would unwind out of the dispatch loop and destroy the
// socket, turning one bogus CDB — a buggy or hostile initiator's — into the
// loss of the whole session and every command in flight on it.
for (const [name, cdb] of [
['READ(16)', read16(0xffffffffffffffffn, 1)],
['WRITE(16)', write16(0xffffffffffffffffn, 1)],
] as const) {
it(`answers a ${name} past the addressable range with CHECK CONDITION, keeping the session`, async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 31, cmdSN: 1, cdb }))
const [, response] = await harness.untilSent(2)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.CHECK_CONDITION)
assert.equal(senseKey(response), SenseKey.ILLEGAL_REQUEST)
assert.equal(
harness.sent.some(pdu => pdu.opcode === TargetOpcode.R2T),
false
)
// the connection is still serving: a following command is answered
harness.socket.deliver(scsiCommandPdu({ itt: 32, cmdSN: 2, cdb: inquiry(36) }))
const [, , dataIn] = await harness.untilSent(3)
assert.equal(dataIn.opcode, TargetOpcode.SCSI_DATA_IN)
})
})
}
})

View File

@@ -0,0 +1,200 @@
// iSCSI (RFC 7143 / RFC 3720) and SCSI (SPC-3 / SBC-3) protocol constants.
//
// Only the subset required by a minimal single-LUN target is defined here.
// --- iSCSI PDU layout -------------------------------------------------------
/** Length of the Basic Header Segment, in bytes. Fixed by the protocol. */
export const BHS_LENGTH = 48
/** Bit set in byte 0 for initiator PDUs that must be processed immediately. */
export const OPCODE_IMMEDIATE = 0x40
/** Mask extracting the 6-bit opcode from byte 0. */
export const OPCODE_MASK = 0x3f
/** Final bit, byte 1, set on the last PDU of a sequence. */
export const FLAG_FINAL = 0x80
/** Sentinel for "no tag" in Initiator/Target Task Tag fields. */
export const RESERVED_TAG = 0xffffffff
// --- iSCSI opcodes ----------------------------------------------------------
/** Opcodes sent by the initiator (request PDUs). */
export const InitiatorOpcode = {
NOP_OUT: 0x00,
SCSI_COMMAND: 0x01,
SCSI_TASK_MGMT_REQUEST: 0x02,
LOGIN_REQUEST: 0x03,
TEXT_REQUEST: 0x04,
SCSI_DATA_OUT: 0x05,
LOGOUT_REQUEST: 0x06,
SNACK_REQUEST: 0x10,
} as const
/** Opcodes sent by the target (response PDUs). */
export const TargetOpcode = {
NOP_IN: 0x20,
SCSI_RESPONSE: 0x21,
SCSI_TASK_MGMT_RESPONSE: 0x22,
LOGIN_RESPONSE: 0x23,
TEXT_RESPONSE: 0x24,
SCSI_DATA_IN: 0x25,
LOGOUT_RESPONSE: 0x26,
R2T: 0x31,
ASYNC_MESSAGE: 0x32,
REJECT: 0x3f,
} as const
// --- Login negotiation ------------------------------------------------------
/** Login/text stage codes used in the CSG/NSG fields. */
export const LoginStage = {
SECURITY_NEGOTIATION: 0,
OPERATIONAL_NEGOTIATION: 1,
FULL_FEATURE_PHASE: 3,
} as const
/** Login Request/Response byte 1 flags. */
export const LOGIN_FLAG_TRANSIT = 0x80
export const LOGIN_FLAG_CONTINUE = 0x40
/** Mask + shift for the Current Stage (CSG) field in byte 1. */
export const LOGIN_CSG_MASK = 0x0c
export const LOGIN_CSG_SHIFT = 2
/** Mask for the Next Stage (NSG) field in byte 1. */
export const LOGIN_NSG_MASK = 0x03
/** iSCSI version (only version 0 exists). */
export const ISCSI_VERSION = 0x00
/** Login Response Status-Class values. */
export const LoginStatusClass = {
SUCCESS: 0x00,
REDIRECTION: 0x01,
INITIATOR_ERROR: 0x02,
TARGET_ERROR: 0x03,
} as const
/**
* Login Response Status-Detail values (byte 37), qualifying the Status-Class.
* `AUTHENTICATION_FAILURE` pairs with `INITIATOR_ERROR` to reject a bad CHAP
* login (RFC 7143 §11.1.5).
*/
export const LoginStatusDetail = {
SUCCESS: 0x00,
AUTHENTICATION_FAILURE: 0x01,
} as const
/** `AuthMethod` login-key values we understand (RFC 7143 §12.1). */
export const AuthMethod = {
NONE: 'None',
CHAP: 'CHAP',
} as const
// --- Text/Data flags --------------------------------------------------------
export const TEXT_FLAG_FINAL = 0x80
export const TEXT_FLAG_CONTINUE = 0x40
/** SCSI Data-In byte 1 flags. */
export const DATA_IN_FLAG_FINAL = 0x80
export const DATA_IN_FLAG_ACK = 0x40
export const DATA_IN_FLAG_OVERFLOW = 0x04
export const DATA_IN_FLAG_UNDERFLOW = 0x02
export const DATA_IN_FLAG_STATUS = 0x01
/** SCSI Command (initiator) byte 1 flags. */
export const SCSI_CMD_FLAG_READ = 0x40
export const SCSI_CMD_FLAG_WRITE = 0x20
/** SCSI Response byte 1 residual flags. */
export const SCSI_RESP_FLAG_OVERFLOW = 0x04
export const SCSI_RESP_FLAG_UNDERFLOW = 0x02
/** SCSI Response "Response" field (byte 2). */
export const SCSI_RESPONSE_COMMAND_COMPLETED = 0x00
/** Logout reason codes (byte 1, low 7 bits). */
export const LogoutReason = {
CLOSE_SESSION: 0,
CLOSE_CONNECTION: 1,
REMOVE_CONNECTION_FOR_RECOVERY: 2,
} as const
/** Logout Response codes (byte 2). */
export const LogoutResponse = {
SUCCESS: 0,
} as const
// --- SCSI status codes ------------------------------------------------------
export const ScsiStatus = {
GOOD: 0x00,
CHECK_CONDITION: 0x02,
} as const
// --- SCSI CDB opcodes -------------------------------------------------------
export const ScsiOpcode = {
TEST_UNIT_READY: 0x00,
REQUEST_SENSE: 0x03,
INQUIRY: 0x12,
MODE_SENSE_6: 0x1a,
READ_CAPACITY_10: 0x25,
READ_10: 0x28,
WRITE_10: 0x2a,
SYNCHRONIZE_CACHE_10: 0x35,
MODE_SENSE_10: 0x5a,
READ_16: 0x88,
WRITE_16: 0x8a,
SYNCHRONIZE_CACHE_16: 0x91,
SERVICE_ACTION_IN_16: 0x9e, // READ CAPACITY(16) lives under this opcode
REPORT_LUNS: 0xa0,
} as const
/** Service action (CDB byte 1, low 5 bits) for SERVICE_ACTION_IN_16. */
export const SERVICE_ACTION_READ_CAPACITY_16 = 0x10
// --- SCSI sense keys / additional sense codes -------------------------------
export const SenseKey = {
NO_SENSE: 0x00,
RECOVERED_ERROR: 0x01,
NOT_READY: 0x02,
MEDIUM_ERROR: 0x03,
HARDWARE_ERROR: 0x04,
ILLEGAL_REQUEST: 0x05,
UNIT_ATTENTION: 0x06,
DATA_PROTECT: 0x07,
} as const
/** Additional Sense Code / Qualifier pairs we may emit. */
export const Asc = {
NO_ADDITIONAL_SENSE: { asc: 0x00, ascq: 0x00 },
INVALID_COMMAND_OPERATION_CODE: { asc: 0x20, ascq: 0x00 },
INVALID_FIELD_IN_CDB: { asc: 0x24, ascq: 0x00 },
LOGICAL_BLOCK_ADDRESS_OUT_OF_RANGE: { asc: 0x21, ascq: 0x00 },
WRITE_PROTECTED: { asc: 0x27, ascq: 0x00 },
} as const
/** Reject reason codes (Reject PDU byte 2). */
export const RejectReason = {
PROTOCOL_ERROR: 0x04,
COMMAND_NOT_SUPPORTED: 0x05,
} as const
// --- debugging --------------------------------------------------------------
// Initiator opcodes (0x00-0x10) and target opcodes (0x20-0x3f) never collide,
// so one reverse map names both directions for logs.
const OPCODE_NAMES: Record<number, string> = {
...Object.fromEntries(Object.entries(InitiatorOpcode).map(([name, code]) => [code, name])),
...Object.fromEntries(Object.entries(TargetOpcode).map(([name, code]) => [code, name])),
}
/** Human-readable name of a PDU opcode (for debug logs). */
export function opcodeName(opcode: number): string {
const code = opcode & OPCODE_MASK
return OPCODE_NAMES[code] ?? `0x${code.toString(16).padStart(2, '0')}`
}

View File

@@ -0,0 +1,30 @@
// Ambient declarations for untyped external packages used by this module.
// (CLAUDE.md: "Add declaration.d.ts for untyped external packages.")
declare module '@vates/read-chunk' {
import type { Readable } from 'node:stream'
/**
* Resolves to exactly `size` bytes read from `stream`, or `null` if the
* stream ends before any byte of the requested chunk is available.
*/
export function readChunk(stream: Readable, size?: number): Promise<Buffer | null>
/**
* Resolves to exactly `size` bytes read from `stream`; rejects if the stream
* ends before `size` bytes have been read.
*/
export function readChunkStrict(stream: Readable, size: number): Promise<Buffer>
}
declare module 'promise-toolbox' {
/**
* Reject the promise it is bound to if it does not settle within `delay`
* milliseconds. Used as `pTimeout.call(promise, delay)`.
*/
interface PTimeout {
<T>(this: Promise<T>, delay: number, onReject?: () => void): Promise<T>
call<T>(promise: Promise<T>, delay: number, onReject?: () => void): Promise<T>
}
export const pTimeout: PTimeout
}

281
@vates/iscsi/src/index.mts Normal file
View File

@@ -0,0 +1,281 @@
import { createServer, type AddressInfo, type Server, type Socket } from 'node:net'
import { createLogger, type Logger } from '@xen-orchestra/log'
import type { BlockDevice } from './backend.mjs'
import { Connection, type ConnectionDeps } from './connection.mjs'
import { DEFAULT_MAX_TRANSFER_LENGTH, type ScsiIdentity } from './scsi.mjs'
import type { ChapCredentials } from './types.mjs'
export { FileBlockDevice } from './backend.mjs'
export type { BlockDevice, FileBlockDeviceOptions } from './backend.mjs'
export type { ScsiIdentity } from './scsi.mjs'
export type { ChapCredentials } from './types.mjs'
export { IscsiInitiator } from './initiator.mjs'
export type { IscsiInitiatorOptions } from './initiator.mjs'
export { IscsiDisk } from './IscsiDisk.mjs'
export type { IscsiDiskOptions } from './IscsiDisk.mjs'
export { DiskBlockDevice } from './DiskBlockDevice.mjs'
export type { DiskBlockDeviceOptions } from './DiskBlockDevice.mjs'
export { CachedDiskBlockDevice } from './CachedDiskBlockDevice.mjs'
export type { CachedDiskBlockDeviceOptions } from './CachedDiskBlockDevice.mjs'
const log: Logger = createLogger('vates:iscsi')
const DEFAULT_PORT = 3260
const DEFAULT_WRITE_TIMEOUT_MS = 30_000
const DEFAULT_CMD_WINDOW = 64
const DEFAULT_READ_CONCURRENCY = 16
const DEFAULT_LOGIN_TIMEOUT_MS = 15_000
const DEFAULT_MAX_PENDING_CONNECTIONS = 8
const DEFAULT_IDENTITY: ScsiIdentity = {
vendor: 'VATES',
product: 'ISCSI LUN',
revision: '0001',
serial: '',
}
export interface IscsiTargetOptions {
/** The target's iSCSI Qualified Name, e.g. `iqn.2024-01.tech.vates:lun0`. */
readonly iqn: string
/** The single LUN exposed by this target. */
readonly lun: BlockDevice
/** Listen address. Defaults to all interfaces. */
readonly host?: string
/** Listen port. Defaults to 3260. */
readonly port?: number
/** Overrides for the SCSI INQUIRY identity strings. */
readonly identity?: Partial<ScsiIdentity>
/** Drain timeout for outbound PDUs in ms (0 disables). Defaults to 30000. */
readonly writeTimeoutMs?: number
/** CmdSN command-window depth advertised to the initiator. Defaults to 64. */
readonly cmdWindow?: number
/**
* Max number of READ commands served concurrently — the command window lets
* the initiator keep many outstanding, but this caps how many `lun.read()`
* calls actually run at once regardless (e.g. so a deep queue depth doesn't
* fire dozens of simultaneous fetches against a real backend). Defaults to 16.
*/
readonly readConcurrency?: number
/**
* When set, require one-way CHAP: the target challenges each initiator and
* rejects the login unless it proves this credential (interop with the
* open-iscsi `node.session.auth` username/password). Omit for no authentication.
*/
readonly chap?: ChapCredentials
/**
* Time a new connection has to establish a Normal session, in ms (0
* disables). Also bounds Discovery sessions. Defaults to 15000.
*/
readonly loginTimeoutMs?: number
/**
* Max number of connections that have not established a Normal session yet;
* past it, new ones are refused. Defaults to 8.
*/
readonly maxPendingConnections?: number
/**
* Source addresses the initiators may connect from. When set, connections
* from any other address are closed as soon as they are accepted, before any
* login: only these initiators can take a pending connection slot.
* IPv4-mapped IPv6 addresses (`::ffff:a.b.c.d`) match their IPv4 form.
*/
readonly allowedInitiatorAddresses?: Iterable<string>
/**
* Called with the source address of each connection closed because it is
* not in `allowedInitiatorAddresses`, e.g. to tell why an initiator behind a
* NAT cannot connect.
*/
readonly onConnectionRefused?: (address: string) => void
/**
* Max length of a single READ or WRITE, in bytes, advertised in the block
* limits VPD page; longer commands are refused. At least one logical block.
* Defaults to 8 MiB.
*/
readonly maxTransferLength?: number
}
const IPV4_MAPPED_PREFIX = '::ffff:'
function normalizeAddress(address: string): string {
return address.startsWith(IPV4_MAPPED_PREFIX) && address.includes('.')
? address.slice(IPV4_MAPPED_PREFIX.length)
: address
}
/**
* A minimal iSCSI target exposing exactly one read/write LUN.
*
* Single initiator, single connection (`MaxConnections=1`),
* `ErrorRecoveryLevel=0`, no digests.
*
* A new connection replaces the current one rather than being refused (see
* `#onSessionEstablished`): there is no way to tell a stale, abandoned
* connection (initiator gave up without closing the socket) from a healthy
* one, so refusing would let one stuck connection wedge the target forever.
* The replacement only happens once the new connection has established a
* Normal session, CHAP included when configured, so that a port scan, a failed
* login or a Discovery session cannot interrupt the current one. Until then,
* connections are bounded in time (`loginTimeoutMs`) and in number
* (`maxPendingConnections`); with `allowedInitiatorAddresses`, only the expected
* initiators can take those slots. Not RFC 7143 session reinstatement (no ISID
* matching) — safe here since the target is ephemeral, CHAP-guarded, and
* single-consumer.
*/
export class IscsiTarget {
readonly #iqn: string
readonly #lun: BlockDevice
readonly #host?: string
readonly #port: number
readonly #identity: ScsiIdentity
readonly #writeTimeoutMs: number
readonly #cmdWindow: number
readonly #readConcurrency: number
readonly #chap?: ChapCredentials
readonly #loginTimeoutMs: number
readonly #maxPendingConnections: number
readonly #allowedInitiatorAddresses?: ReadonlySet<string>
readonly #onConnectionRefused?: (address: string) => void
readonly #maxTransferLength: number
#server?: Server
// the connection with an established Normal session, serving the LUN
#connection?: Connection
// every open connection, so that close() can drop them all
readonly #connections = new Set<Connection>()
#tsih = 0
constructor(options: IscsiTargetOptions) {
this.#iqn = options.iqn
this.#lun = options.lun
this.#host = options.host
this.#port = options.port ?? DEFAULT_PORT
this.#writeTimeoutMs = options.writeTimeoutMs ?? DEFAULT_WRITE_TIMEOUT_MS
this.#cmdWindow = options.cmdWindow ?? DEFAULT_CMD_WINDOW
this.#readConcurrency = options.readConcurrency ?? DEFAULT_READ_CONCURRENCY
this.#chap = options.chap
this.#loginTimeoutMs = options.loginTimeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS
this.#maxPendingConnections = options.maxPendingConnections ?? DEFAULT_MAX_PENDING_CONNECTIONS
if (options.allowedInitiatorAddresses !== undefined) {
this.#allowedInitiatorAddresses = new Set(Array.from(options.allowedInitiatorAddresses, normalizeAddress))
}
this.#onConnectionRefused = options.onConnectionRefused
this.#maxTransferLength = options.maxTransferLength ?? DEFAULT_MAX_TRANSFER_LENGTH
if (this.#maxTransferLength < options.lun.getBlockSize()) {
throw new RangeError('maxTransferLength must be at least one logical block')
}
this.#identity = {
...DEFAULT_IDENTITY,
// Default the serial to the IQN so the LUN has a stable, unique identity.
serial: options.iqn,
...options.identity,
}
}
#allocateTsih(): number {
this.#tsih = (this.#tsih % 0xffff) + 1 // non-zero, wraps within 16 bits
return this.#tsih
}
#deps(): ConnectionDeps {
return {
iqn: this.#iqn,
identity: this.#identity,
lun: this.#lun,
writeTimeoutMs: this.#writeTimeoutMs,
cmdWindow: this.#cmdWindow,
readConcurrency: this.#readConcurrency,
allocateTsih: () => this.#allocateTsih(),
chap: this.#chap,
loginTimeoutMs: this.#loginTimeoutMs,
onSessionEstablished: connection => this.#onSessionEstablished(connection),
maxTransferLength: this.#maxTransferLength,
}
}
#onConnection(socket: Socket): void {
const allowed = this.#allowedInitiatorAddresses
const remote = normalizeAddress(socket.remoteAddress ?? '')
if (allowed !== undefined && !allowed.has(remote)) {
log.warn('connection from an address that is not allowed, closing it', { remote })
socket.destroy()
this.#onConnectionRefused?.(remote)
return
}
socket.setNoDelay(true)
const connection = new Connection(socket, this.#deps())
this.#connections.add(connection)
// Long-lived per-connection task; serve() handles its own errors and never
// rejects, so detaching it here is safe.
void connection.serve().finally(() => {
this.#connections.delete(connection)
if (this.#connection === connection) {
this.#connection = undefined
}
})
}
#onSessionEstablished(connection: Connection): void {
const existing = this.#connection
this.#connection = connection
if (existing !== undefined) {
// Replaces a possibly-stale connection rather than refusing; see the class doc.
log.warn('replacing existing connection with a newly logged in one')
existing.destroy()
}
}
/** Open the LUN (if needed) and start accepting connections. */
async listen(): Promise<void> {
if (this.#server !== undefined) {
throw new Error('iSCSI target is already listening')
}
await this.#lun.open?.()
const server = createServer(socket => this.#onConnection(socket))
// + 1: the connection serving the LUN does not take a pending slot
server.maxConnections = this.#maxPendingConnections + 1
server.on('drop', data => {
log.warn('too many connections without a session, refusing a new one', { remote: data?.remoteAddress })
})
this.#server = server
try {
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => reject(error)
server.once('error', onError)
server.listen(this.#port, this.#host, () => {
server.removeListener('error', onError)
resolve()
})
})
} catch (error) {
this.#server = undefined
throw error
}
log.info('target listening', { iqn: this.#iqn, address: this.address() })
}
/** The bound address, or undefined if not listening on an IP socket. */
address(): AddressInfo | undefined {
const address = this.#server?.address()
return address !== null && typeof address === 'object' ? address : undefined
}
/** Stop accepting connections, drop every connection, and close the LUN. */
async close(): Promise<void> {
const server = this.#server
const connections = [...this.#connections]
this.#server = undefined
this.#connection = undefined
this.#connections.clear()
if (server !== undefined) {
await new Promise<void>((resolve, reject) => {
server.close(error => (error ? reject(error) : resolve()))
// Drop the live connections so server.close() can complete.
for (const connection of connections) {
connection.destroy()
}
})
}
await this.#lun.close()
}
}

View File

@@ -0,0 +1,261 @@
import assert from 'node:assert/strict'
import { createServer, type Server, type Socket } from 'node:net'
import { once } from 'node:events'
import { describe, it } from 'node:test'
import {
DATA_IN_FLAG_FINAL,
DATA_IN_FLAG_STATUS,
FLAG_FINAL,
InitiatorOpcode,
ISCSI_VERSION,
LOGIN_FLAG_TRANSIT,
LoginStage,
RESERVED_TAG,
ScsiStatus,
TargetOpcode,
} from './constants.mjs'
import { IscsiInitiator } from './initiator.mjs'
import { allocBhs, assemblePdu, type IncomingPdu, readPdu } from './pdu.mjs'
const IQN = 'iqn.2024-01.tech.vates:nop-test'
const BLOCK_SIZE = 512
const BLOCK_COUNT = 2048
/**
* A target that speaks just enough to get a session up — login, READ
* CAPACITY(16), READ(16) — and can then ping the initiator on demand, which
* the real {@link IscsiTarget} never does. Everything here is about what the
* *initiator* does with an unsolicited NOP-In.
*/
class PingingTarget {
readonly #server: Server
#socket?: Socket
/** PDUs received from the initiator after the session was established. */
readonly received: Array<IncomingPdu> = []
#statSN = 0
constructor() {
this.#server = createServer(socket => {
this.#socket = socket
socket.setNoDelay(true)
this.#serve(socket).catch(() => {}) // the initiator hanging up is normal
})
}
async listen(): Promise<number> {
this.#server.listen(0, '127.0.0.1')
await once(this.#server, 'listening')
return (this.#server.address() as { port: number }).port
}
async close(): Promise<void> {
this.#socket?.destroy()
this.#server.close()
}
#send(socket: Socket, buffer: Buffer): Promise<void> {
return new Promise((resolve, reject) => {
socket.write(buffer, error => (error ? reject(error) : resolve()))
})
}
/** Ping the initiator the way a target does when the session goes quiet. */
async ping(targetTransferTag: number, data = Buffer.alloc(0)): Promise<void> {
const socket = this.#socket
assert.ok(socket !== undefined, 'no initiator connected')
const bhs = allocBhs(TargetOpcode.NOP_IN)
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(RESERVED_TAG, 16) // ITT: unsolicited, so none
bhs.writeUInt32BE(targetTransferTag, 20)
bhs.writeUInt32BE(this.#statSN, 24) // NOP-In does not advance StatSN
await this.#send(socket, assemblePdu(bhs, data))
}
/** Resolves once the initiator has sent a PDU with `opcode`. */
async waitFor(opcode: number, timeoutMs = 2000): Promise<IncomingPdu> {
const deadline = Date.now() + timeoutMs
for (;;) {
const found = this.received.find(pdu => pdu.opcode === opcode)
if (found !== undefined) {
return found
}
assert.ok(Date.now() < deadline, `initiator never sent opcode 0x${opcode.toString(16)}`)
await new Promise(resolve => setTimeout(resolve, 10))
}
}
async #serve(socket: Socket): Promise<void> {
let established = false
for (;;) {
const pdu = await readPdu(socket)
if (pdu === null) {
return
}
if (established) {
this.received.push(pdu)
}
switch (pdu.opcode) {
case InitiatorOpcode.LOGIN_REQUEST:
await this.#sendLoginResponse(socket, pdu)
established = true
break
case InitiatorOpcode.SCSI_COMMAND:
await this.#sendReadData(socket, pdu)
break
case InitiatorOpcode.LOGOUT_REQUEST: {
const bhs = allocBhs(TargetOpcode.LOGOUT_RESPONSE)
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(pdu.itt, 16)
bhs.writeUInt32BE(this.#statSN++, 24)
await this.#send(socket, assemblePdu(bhs))
return
}
default: // NOP-Out replies land in `received`; nothing to answer
break
}
}
}
async #sendLoginResponse(socket: Socket, request: IncomingPdu): Promise<void> {
const bhs = allocBhs(TargetOpcode.LOGIN_RESPONSE)
bhs[1] = LOGIN_FLAG_TRANSIT | (LoginStage.OPERATIONAL_NEGOTIATION << 2) | LoginStage.FULL_FEATURE_PHASE
bhs[2] = ISCSI_VERSION
bhs[3] = ISCSI_VERSION
request.bhs.copy(bhs, 8, 8, 14) // echo ISID
bhs.writeUInt16BE(1, 14) // TSIH
bhs.writeUInt32BE(request.itt, 16)
bhs.writeUInt32BE(this.#statSN++, 24)
await this.#send(socket, assemblePdu(bhs))
}
/** Answer any read-type command with a single Data-In carrying GOOD status. */
async #sendReadData(socket: Socket, command: IncomingPdu): Promise<void> {
const cdb = command.bhs.subarray(32, 48)
const expectedLength = command.readU32(20)
let payload
if (cdb[0] === 0x9e) {
// SERVICE ACTION IN(16) / READ CAPACITY(16)
payload = Buffer.alloc(expectedLength)
payload.writeBigUInt64BE(BigInt(BLOCK_COUNT - 1), 0)
payload.writeUInt32BE(BLOCK_SIZE, 8)
} else {
payload = Buffer.alloc(expectedLength, 0x5a)
}
const bhs = allocBhs(TargetOpcode.SCSI_DATA_IN)
bhs[1] = DATA_IN_FLAG_FINAL | DATA_IN_FLAG_STATUS
bhs[3] = ScsiStatus.GOOD
bhs.writeUInt32BE(command.itt, 16)
bhs.writeUInt32BE(RESERVED_TAG, 20)
bhs.writeUInt32BE(this.#statSN++, 24)
await this.#send(socket, assemblePdu(bhs, payload))
}
}
/** Run `body` against a connected initiator, always tearing both ends down. */
const withSession = async (body: (initiator: IscsiInitiator, target: PingingTarget) => Promise<void>) => {
const target = new PingingTarget()
const port = await target.listen()
const initiator = new IscsiInitiator({
host: '127.0.0.1',
port,
targetIqn: IQN,
messageTimeoutMs: 5000,
})
await initiator.connect()
try {
await body(initiator, target)
} finally {
await initiator.close()
await target.close()
}
}
describe('NOP-In keepalives', () => {
// The read loop used to stop as soon as no read was outstanding, and it is
// the only reader of the socket — so a ping arriving while idle sat unread in
// a paused stream. Targets drop a session whose pings go unanswered (30s on
// LIO by default), and nothing here reconnects, so an idle mount died.
it('are answered while the session is idle', async () => {
await withSession(async (initiator, target) => {
// no read in flight, and none will be
await target.ping(7)
const reply = await target.waitFor(InitiatorOpcode.NOP_OUT)
assert.equal(reply.readU32(20), 7) // echoes the Target Transfer Tag
assert.equal(reply.itt, RESERVED_TAG) // unsolicited reply, so no ITT
assert.notEqual(reply.bhs[0] & 0x40, 0) // immediate
})
})
it('echo the ping payload back', async () => {
await withSession(async (initiator, target) => {
const ping = Buffer.from('are you there')
await target.ping(9, ping)
const reply = await target.waitFor(InitiatorOpcode.NOP_OUT)
assert.deepEqual(reply.data, ping)
})
})
it('are answered between reads, without disturbing them', async () => {
await withSession(async (initiator, target) => {
assert.equal((await initiator.read(0, BLOCK_SIZE)).length, BLOCK_SIZE)
await target.ping(11)
await target.waitFor(InitiatorOpcode.NOP_OUT)
// the session is still usable afterwards
const data = await initiator.read(BLOCK_SIZE, BLOCK_SIZE)
assert.deepEqual(data, Buffer.alloc(BLOCK_SIZE, 0x5a))
})
})
it('are ignored when they are a reply to our own ping', async () => {
await withSession(async (initiator, target) => {
// a NOP-In with no Target Transfer Tag is a reply, not a ping: answering
// it would start an endless exchange
await target.ping(RESERVED_TAG)
await new Promise(resolve => setTimeout(resolve, 200))
assert.equal(
target.received.some(pdu => pdu.opcode === InitiatorOpcode.NOP_OUT),
false
)
// and the session still works
assert.equal((await initiator.read(0, BLOCK_SIZE)).length, BLOCK_SIZE)
})
})
})
describe('session teardown', () => {
it('logs out cleanly even though the read loop owns the socket', async () => {
const target = new PingingTarget()
const port = await target.listen()
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, messageTimeoutMs: 5000 })
await initiator.connect()
await initiator.close()
// the Logout Request reached the target, i.e. `close()` did not give up on
// its own response for lack of a reader
await target.waitFor(InitiatorOpcode.LOGOUT_REQUEST)
await target.close()
})
it('fails a read with the reason the session died, rather than timing out', async () => {
const target = new PingingTarget()
const port = await target.listen()
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, messageTimeoutMs: 5000 })
await initiator.connect()
await target.close() // target vanishes while the initiator is idle
await new Promise(resolve => setTimeout(resolve, 100))
const started = Date.now()
await assert.rejects(initiator.read(0, BLOCK_SIZE), /connection closed/)
// far below messageTimeoutMs: the dead session is known, not discovered
assert.ok(Date.now() - started < 1000)
})
})

View File

@@ -0,0 +1,501 @@
import { connect, type Socket } from 'node:net'
import { once } from 'node:events'
import { createLogger, type Logger } from '@xen-orchestra/log'
import { pTimeout } from 'promise-toolbox'
import {
AuthMethod,
DATA_IN_FLAG_STATUS,
FLAG_FINAL,
InitiatorOpcode,
LOGIN_CSG_SHIFT,
LOGIN_FLAG_TRANSIT,
LOGIN_NSG_MASK,
LoginStage,
LoginStatusClass,
OPCODE_IMMEDIATE,
RESERVED_TAG,
SCSI_CMD_FLAG_READ,
ScsiOpcode,
ScsiStatus,
SERVICE_ACTION_READ_CAPACITY_16,
TargetOpcode,
} from './constants.mjs'
import { CHAP_ALGORITHM_MD5, computeResponse, decodeChapValue, encodeChapValue } from './chap.mjs'
import { parseTextKeys, serializeTextKeys } from './login.mjs'
import { allocBhs, assemblePdu, type IncomingPdu, readPdu, writePdu } from './pdu.mjs'
import type { ChapCredentials } from './types.mjs'
const log: Logger = createLogger('vates:iscsi:initiator')
const DEFAULT_PORT = 3260
const DEFAULT_MESSAGE_TIMEOUT_MS = 60_000
const DEFAULT_MAX_RECV_DATA_SEGMENT_LENGTH = 262144
const READ_CAPACITY_16_LENGTH = 32
export interface IscsiInitiatorOptions {
/** Portal address to connect to. */
readonly host: string
/** Portal port. Defaults to 3260. */
readonly port?: number
/** Target IQN (`TargetName`) to log into. */
readonly targetIqn: string
/** Our initiator IQN (`InitiatorName`). Defaults to a generated one. */
readonly initiatorIqn?: string
/**
* One-way CHAP responder credential. When the target requests CHAP we answer
* its challenge with this user/secret. Omit for an unauthenticated login.
*/
readonly chap?: ChapCredentials
/** Per-message timeout for login/read responses, in ms. Defaults to 60000. */
readonly messageTimeoutMs?: number
}
/** An outstanding SCSI read, keyed by its Initiator Task Tag. */
interface Pending {
/** Preallocated buffer sized to the expected transfer length. */
readonly buffer: Buffer
resolve(data: Buffer): void
reject(error: Error): void
}
/**
* A minimal userspace iSCSI initiator (client), mirroring {@link IscsiTarget}'s
* framing and negotiation (read-only, single connection, one LUN, digests
* off, `ErrorRecoveryLevel=0`).
*
* A backlog `Map` keyed by ITT, drained by a single read loop, demultiplexes
* responses so several reads can be outstanding at once (e.g. under a
* `ReadAhead` disk wrapper).
*/
export class IscsiInitiator {
readonly #host: string
readonly #port: number
readonly #targetIqn: string
readonly #initiatorIqn: string
readonly #chap?: ChapCredentials
readonly #messageTimeoutMs: number
#socket?: Socket
#itt = 0
#cmdSN = 0
#expStatSN = 0
#blockSize = 0
#capacityBytes = 0
// Outstanding reads, keyed by ITT. A single #readLoop() (guarded by #reading)
// is the only consumer of readPdu, so it is safe to have many entries here.
readonly #backlog = new Map<number, Pending>()
#reading = false
// set once `close()` starts, so the read loop's own end is not reported as a
// fault and it stops instead of racing the socket teardown
#closing = false
// why the read loop stopped, when it was not us closing
#failure?: Error
// resolved by the read loop when the target's Logout Response arrives
#logoutAnswered?: () => void
constructor(options: IscsiInitiatorOptions) {
this.#host = options.host
this.#port = options.port ?? DEFAULT_PORT
this.#targetIqn = options.targetIqn
this.#initiatorIqn = options.initiatorIqn ?? 'iqn.2024-01.tech.vates:initiator'
this.#chap = options.chap
this.#messageTimeoutMs = options.messageTimeoutMs ?? DEFAULT_MESSAGE_TIMEOUT_MS
}
/** LUN logical block size in bytes (valid after {@link connect}). */
getBlockSize(): number {
return this.#blockSize
}
/** LUN capacity in bytes (valid after {@link connect}). */
getSize(): number {
return this.#capacityBytes
}
// --- lifecycle ------------------------------------------------------------
/** Open the TCP connection, log in, and read the LUN capacity. */
async connect(): Promise<void> {
const socket = connect(this.#port, this.#host)
this.#socket = socket
try {
await once(socket, 'connect')
socket.setNoDelay(true)
// A socket error with no read in flight would otherwise go unnoticed.
socket.on('error', error => this.#rejectAll(error instanceof Error ? error : new Error(String(error))))
// `#login` reads its own responses; from here on the read loop owns the
// socket, and it must be running before anything can answer a NOP-In
await this.#login()
void this.#pump()
await this.#readCapacity()
log.info('session established', { target: this.#targetIqn, capacity: this.#capacityBytes })
} catch (error) {
socket.destroy()
this.#socket = undefined
throw error
}
}
#requireSocket(): Socket {
const socket = this.#socket
if (socket === undefined) {
throw new Error('IscsiInitiator.connect() must be called first')
}
return socket
}
#send(buffer: Buffer): Promise<void> {
return writePdu(this.#requireSocket(), buffer, this.#messageTimeoutMs)
}
#nextItt(): number {
this.#itt = (this.#itt + 1) >>> 0
return this.#itt
}
/** Best-effort Logout, then close the socket. */
async close(): Promise<void> {
const socket = this.#socket
if (socket === undefined) {
return
}
// stops the read loop and keeps its own end from being logged as a fault
this.#closing = true
try {
const bhs = allocBhs(InitiatorOpcode.LOGOUT_REQUEST | OPCODE_IMMEDIATE)
bhs[1] = FLAG_FINAL // reason 0: close the session
bhs.writeUInt32BE(this.#nextItt(), 16)
bhs.writeUInt32BE(this.#cmdSN, 24)
bhs.writeUInt32BE(this.#expStatSN, 28)
// the read loop owns the socket, so the response comes through it: reading
// here too would put two consumers on one stream and split PDUs between them
const answered = new Promise<void>(resolve => {
this.#logoutAnswered = resolve
})
await this.#send(assemblePdu(bhs))
await pTimeout.call(answered, this.#messageTimeoutMs)
} catch (error) {
log.debug('logout failed, closing anyway', { error })
} finally {
this.#logoutAnswered = undefined
this.#socket = undefined
socket.destroy()
}
}
// --- login ----------------------------------------------------------------
/** Build the flags byte (T/CSG/NSG) of a Login Request. */
#loginFlags(transit: boolean, csg: number, nsg: number): number {
return (transit ? LOGIN_FLAG_TRANSIT : 0) | (csg << LOGIN_CSG_SHIFT) | (nsg & LOGIN_NSG_MASK)
}
async #sendLogin(itt: number, flags: number, keys: Array<[string, string]>): Promise<IncomingPdu> {
const bhs = allocBhs(InitiatorOpcode.LOGIN_REQUEST | OPCODE_IMMEDIATE)
bhs[1] = flags
Buffer.from([0x80, 0, 0, 0, 0, 1]).copy(bhs, 8) // ISID (fixed; single session)
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(this.#cmdSN, 24) // login is immediate: does not advance CmdSN
bhs.writeUInt32BE(this.#expStatSN, 28)
log.debug('login request', { flags: `0x${flags.toString(16)}`, keys: Object.fromEntries(keys) })
await this.#send(assemblePdu(bhs, serializeTextKeys(keys)))
const pdu = await pTimeout.call(readPdu(this.#requireSocket()), this.#messageTimeoutMs)
if (pdu === null) {
throw new Error('connection closed during login')
}
if (pdu.opcode !== TargetOpcode.LOGIN_RESPONSE) {
throw new Error(`expected Login Response, got opcode 0x${pdu.opcode.toString(16)}`)
}
const statusClass = pdu.readU8(36)
log.debug('login response', {
statusClass,
statusDetail: pdu.readU8(37),
transit: (pdu.readU8(1) & LOGIN_FLAG_TRANSIT) !== 0,
keys: Object.fromEntries(parseTextKeys(pdu.data)),
})
if (statusClass !== LoginStatusClass.SUCCESS) {
throw new Error(
`login rejected (status-class 0x${statusClass.toString(16)}, detail 0x${pdu.readU8(37).toString(16)})`
)
}
this.#expStatSN = (pdu.readU32(24) + 1) >>> 0
return pdu
}
/**
* Drive the login: an optional CHAP security stage (we are the responder),
* then the operational stage into the full-feature phase. All PDUs of one
* login share a single ITT.
*/
async #login(): Promise<void> {
const itt = this.#nextItt()
const names: Array<[string, string]> = [
['InitiatorName', this.#initiatorIqn],
['TargetName', this.#targetIqn],
['SessionType', 'Normal'],
]
if (this.#chap !== undefined) {
// Round 1: offer CHAP (falling back to None) in the security stage.
const r1 = await this.#sendLogin(
itt,
this.#loginFlags(false, LoginStage.SECURITY_NEGOTIATION, LoginStage.OPERATIONAL_NEGOTIATION),
[...names, ['AuthMethod', `${AuthMethod.CHAP},${AuthMethod.NONE}`]]
)
const method = parseTextKeys(r1.data).get('AuthMethod')
log.debug('chap: target selected AuthMethod', { method })
if (method === AuthMethod.CHAP) {
// Round 2: propose MD5.
const r2 = await this.#sendLogin(
itt,
this.#loginFlags(false, LoginStage.SECURITY_NEGOTIATION, LoginStage.OPERATIONAL_NEGOTIATION),
[['CHAP_A', String(CHAP_ALGORITHM_MD5)]]
)
// Round 3: answer the target's challenge, transiting to operational.
// (Mutual CHAP is not supported: we never challenge the target; a target
// that *requires* mutual auth will reject this login, surfaced above.)
const challenge = parseTextKeys(r2.data)
const id = Number.parseInt(challenge.get('CHAP_I') ?? '', 10)
const challengeValue = challenge.get('CHAP_C')
if (!Number.isInteger(id) || challengeValue === undefined) {
throw new Error('malformed CHAP challenge from target')
}
const response = computeResponse(id, this.#chap.secret, decodeChapValue(challengeValue))
log.debug('chap: answering challenge', { id, user: this.#chap.user })
await this.#sendLogin(
itt,
this.#loginFlags(true, LoginStage.SECURITY_NEGOTIATION, LoginStage.OPERATIONAL_NEGOTIATION),
[
['CHAP_N', this.#chap.user],
['CHAP_R', encodeChapValue(response)],
]
)
await this.#operationalLogin(itt, [])
return
}
if (method !== AuthMethod.NONE && method !== undefined) {
throw new Error(`target selected unsupported AuthMethod=${method}`)
}
// Target did not require CHAP: continue straight to operational.
await this.#operationalLogin(itt, [])
return
}
// No CHAP: a single operational-stage PDU straight to the full-feature phase.
await this.#operationalLogin(itt, names)
}
/** Operational-stage login PDU, transiting to the full-feature phase. */
async #operationalLogin(itt: number, extraKeys: Array<[string, string]>): Promise<void> {
const pdu = await this.#sendLogin(
itt,
this.#loginFlags(true, LoginStage.OPERATIONAL_NEGOTIATION, LoginStage.FULL_FEATURE_PHASE),
[
...extraKeys,
['HeaderDigest', 'None'],
['DataDigest', 'None'],
['InitialR2T', 'Yes'],
['ImmediateData', 'No'],
['ErrorRecoveryLevel', '0'],
['MaxRecvDataSegmentLength', String(DEFAULT_MAX_RECV_DATA_SEGMENT_LENGTH)],
]
)
if ((pdu.readU8(1) & LOGIN_FLAG_TRANSIT) === 0) {
throw new Error('target did not transit to the full-feature phase')
}
}
// --- read path ------------------------------------------------------------
/**
* Read exactly `length` bytes from byte `offset` of the LUN. `offset` and
* `length` must be block-aligned (multiples of {@link getBlockSize}).
*/
read(offset: number, length: number): Promise<Buffer> {
const blockSize = this.#blockSize
if (offset % blockSize !== 0 || length % blockSize !== 0) {
return Promise.reject(new Error(`unaligned read (offset ${offset}, length ${length}, block ${blockSize})`))
}
const cdb = Buffer.alloc(16)
cdb[0] = ScsiOpcode.READ_16
cdb.writeBigUInt64BE(BigInt(offset / blockSize), 2) // LBA
cdb.writeUInt32BE(length / blockSize, 10) // transfer length, in blocks
return this.#scsiRead(cdb, length)
}
/** Issue READ CAPACITY(16) and record the LUN block size and capacity. */
async #readCapacity(): Promise<void> {
const cdb = Buffer.alloc(16)
cdb[0] = ScsiOpcode.SERVICE_ACTION_IN_16
cdb[1] = SERVICE_ACTION_READ_CAPACITY_16
cdb.writeUInt32BE(READ_CAPACITY_16_LENGTH, 10) // allocation length
// Block size is unknown here, so bypass the alignment guard in read().
const data = await this.#scsiRead(cdb, READ_CAPACITY_16_LENGTH)
const maxLba = data.readBigUInt64BE(0)
this.#blockSize = data.readUInt32BE(8)
this.#capacityBytes = Number((maxLba + 1n) * BigInt(this.#blockSize))
log.debug('read capacity', { blockSize: this.#blockSize, capacityBytes: this.#capacityBytes })
}
/** Issue a read-type SCSI command and resolve with its assembled Data-In. */
#scsiRead(cdb: Buffer, expectedLength: number): Promise<Buffer> {
const failure = this.#failure
if (failure !== undefined) {
// the session is already gone: fail with why, rather than writing into a
// half-closed socket and waiting out the message timeout
return Promise.reject(failure)
}
const socket = this.#requireSocket()
const itt = this.#nextItt()
const bhs = allocBhs(InitiatorOpcode.SCSI_COMMAND)
bhs[1] = FLAG_FINAL | SCSI_CMD_FLAG_READ
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(expectedLength, 20) // Expected Data Transfer Length
bhs.writeUInt32BE(this.#cmdSN, 24)
this.#cmdSN = (this.#cmdSN + 1) >>> 0
bhs.writeUInt32BE(this.#expStatSN, 28)
cdb.copy(bhs, 32)
log.debug('scsi read', { itt, cdb: `0x${cdb[0].toString(16)}`, expectedLength, cmdSN: (this.#cmdSN - 1) >>> 0 })
// Register the pending read BEFORE writing, so a fast response cannot arrive
// before the backlog entry exists. No await between allocate → register →
// send → pump, which keeps concurrent reads correct.
const promise = new Promise<Buffer>((resolve, reject) => {
this.#backlog.set(itt, { buffer: Buffer.alloc(expectedLength), resolve, reject })
})
writePdu(socket, assemblePdu(bhs), this.#messageTimeoutMs).catch(error => {
const pending = this.#backlog.get(itt)
if (pending !== undefined) {
this.#backlog.delete(itt)
pending.reject(error instanceof Error ? error : new Error(String(error)))
}
})
void this.#pump()
return pTimeout.call(promise, this.#messageTimeoutMs)
}
/**
* Drain inbound PDUs for as long as the session lives. Only ever one runs at
* a time, and it is the only reader of the socket after login.
*
* It deliberately does not stop when the backlog empties. A target sends its
* NOP-In keepalives precisely when nothing else is going on, and it is the
* only reader — so a loop that parked between reads would leave those pings
* sitting unread in a paused stream. Targets drop a session whose pings go
* unanswered (LIO's `nopin_response_timeout` is 30s by default, arrays are
* similar), and nothing here reconnects, so the session would die during any
* idle period: right after `connect()`, between reads, or while the consumer
* of a mounted disk stalls.
*/
async #pump(): Promise<void> {
if (this.#reading) {
return
}
this.#reading = true
try {
while (!this.#closing) {
const socket = this.#socket
if (socket === undefined) {
throw new Error('connection closed')
}
const pdu = await readPdu(socket)
if (pdu === null) {
throw new Error('connection closed by target')
}
this.#route(pdu)
}
} catch (error) {
const failure = error instanceof Error ? error : new Error(String(error))
if (!this.#closing) {
// The loop now runs while idle, so it is usually the first to notice the
// session die — with nothing in the backlog to reject and nobody to
// return an error to. Log it where it happens, and keep the reason so
// later reads fail with the original cause instead of whatever the dead
// socket produces.
this.#failure = failure
log.warn('read loop stopped, session is dead', { error: failure })
this.#rejectAll(failure)
}
} finally {
this.#reading = false
}
}
/** Route one inbound PDU to its pending read (by ITT), completing it if done. */
#route(pdu: IncomingPdu): void {
switch (pdu.opcode) {
case TargetOpcode.SCSI_DATA_IN: {
const pending = this.#backlog.get(pdu.itt)
if (pending === undefined) {
return // stale/duplicate; ignore
}
pdu.data.copy(pending.buffer, pdu.readU32(40)) // copy at Buffer Offset
const hasStatus = (pdu.flags & DATA_IN_FLAG_STATUS) !== 0
log.debug('data-in', { itt: pdu.itt, offset: pdu.readU32(40), length: pdu.data.length, final: hasStatus })
if (hasStatus) {
this.#expStatSN = (pdu.readU32(24) + 1) >>> 0
this.#complete(pdu.itt, pending, pdu.readU8(3))
}
return
}
case TargetOpcode.SCSI_RESPONSE: {
const pending = this.#backlog.get(pdu.itt)
if (pending !== undefined) {
this.#expStatSN = (pdu.readU32(24) + 1) >>> 0
this.#complete(pdu.itt, pending, pdu.readU8(3))
}
return
}
case TargetOpcode.NOP_IN: {
// A target-initiated NOP ping (TTT set) must be answered to keep the
// session alive; a NOP reply to our own ping (none here) is ignored.
const ttt = pdu.readU32(20)
if (ttt !== RESERVED_TAG) {
void this.#replyNop(ttt, pdu.data)
}
return
}
case TargetOpcode.LOGOUT_RESPONSE:
// awaited by `close()`, which cannot read the socket itself
this.#logoutAnswered?.()
return
default:
// R2T/Async/Reject are not expected on the read-only path; ignore.
log.debug('ignoring unexpected PDU', { opcode: pdu.opcode })
}
}
#complete(itt: number, pending: Pending, status: number): void {
this.#backlog.delete(itt)
log.debug('read complete', { itt, status, pending: this.#backlog.size })
if (status === ScsiStatus.GOOD) {
pending.resolve(pending.buffer)
} else {
pending.reject(new Error(`SCSI command failed with status 0x${status.toString(16)}`))
}
}
async #replyNop(targetTransferTag: number, ping: Buffer): Promise<void> {
const bhs = allocBhs(InitiatorOpcode.NOP_OUT | OPCODE_IMMEDIATE)
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(RESERVED_TAG, 16) // ITT: none (this is an unsolicited reply)
bhs.writeUInt32BE(targetTransferTag, 20)
bhs.writeUInt32BE(this.#cmdSN, 24)
bhs.writeUInt32BE(this.#expStatSN, 28)
await this.#send(assemblePdu(bhs, ping)).catch(error => log.debug('NOP reply failed', { error }))
}
#rejectAll(error: Error): void {
if (this.#backlog.size > 0) {
log.debug('rejecting all pending reads', { count: this.#backlog.size, error: error.message })
}
for (const [itt, pending] of this.#backlog) {
this.#backlog.delete(itt)
pending.reject(error)
}
}
}

372
@vates/iscsi/src/login.mts Normal file
View File

@@ -0,0 +1,372 @@
import { isIP } from 'node:net'
import { createLogger, type Logger } from '@xen-orchestra/log'
import {
AuthMethod,
LOGIN_CSG_MASK,
LOGIN_CSG_SHIFT,
LOGIN_FLAG_CONTINUE,
LOGIN_FLAG_TRANSIT,
LOGIN_NSG_MASK,
LoginStage,
LoginStatusClass,
LoginStatusDetail,
} from './constants.mjs'
import {
computeResponse,
encodeChapValue,
decodeChapValue,
generateChallenge,
generateId,
parseAlgorithmList,
selectAlgorithm,
verifyResponse,
} from './chap.mjs'
import type { IncomingPdu } from './pdu.mjs'
import type { ChapCredentials, NegotiatedParams, SessionType } from './types.mjs'
const EMPTY = Buffer.alloc(0)
// Target-side login negotiation trace. Enable the `vates:iscsi:login` debug
// namespace to see every negotiated key and CHAP state change — the usual root
// cause when a particular initiator fails to attach.
const log: Logger = createLogger('vates:iscsi:login')
// Operational values the target imposes. The target drives negotiation, so these
// are pinned to a single code path (see plan): all writes are R2T-solicited
// (InitialR2T=Yes, ImmediateData=No), no digests, one connection.
export const OUR_MAX_RECV_DATA_SEGMENT_LENGTH = 262144
const OUR_MAX_BURST_LENGTH = 262144
const DEFAULT_INITIATOR_MAX_RECV_DATA_SEGMENT_LENGTH = 8192 // RFC 7143 default
// a declared MaxRecvDataSegmentLength only applies once logged in: until then,
// both sides keep to the 8192-byte default (RFC 7143 §13.12)
export const LOGIN_MAX_DATA_SEGMENT_LENGTH = 8192
/** Parse a NUL-delimited iSCSI text key=value data segment. */
export function parseTextKeys(data: Buffer): Map<string, string> {
const keys = new Map<string, string>()
for (const pair of data.toString('utf8').split('\0')) {
if (pair.length === 0) {
continue
}
const eq = pair.indexOf('=')
if (eq !== -1) {
keys.set(pair.slice(0, eq), pair.slice(eq + 1))
}
}
return keys
}
/** Serialize key=value pairs into a NUL-delimited (and NUL-terminated) buffer. */
export function serializeTextKeys(entries: ReadonlyArray<readonly [string, string]>): Buffer {
if (entries.length === 0) {
return Buffer.alloc(0)
}
return Buffer.from(entries.map(([key, value]) => `${key}=${value}\0`).join(''), 'utf8')
}
function parsePositiveInt(value: string | undefined, fallback: number): number {
if (value === undefined) {
return fallback
}
const parsed = Number.parseInt(value, 10)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
/**
* Format an `address:port,portal-group-tag` value for a SendTargets
* `TargetAddress`, per the iSCSI grammar:
* - unwrap IPv4-mapped IPv6 (`::ffff:1.2.3.4`, produced by a dual-stack socket
* accepting an IPv4 connection) to plain IPv4, so initiators get a clean,
* matchable portal;
* - bracket real IPv6 literals (`[2001:db8::1]:3260,1`).
*/
export function formatPortal(address: string, port: number): string {
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address)
const normalized = mapped !== null ? mapped[1] : address
const bracketed = isIP(normalized) === 6 ? `[${normalized}]` : normalized
return `${bracketed}:${port},1`
}
/** Build the SendTargets discovery Text Response data segment. */
export function buildSendTargetsResponse(iqn: string, address: string, port: number): Buffer {
return serializeTextKeys([
['TargetName', iqn],
['TargetAddress', formatPortal(address, port)],
])
}
/** Outcome of processing a single Login Request PDU. */
export interface LoginStepResult {
/** Byte 1 (T/C/CSG/NSG) of the Login Response. */
flagsByte: number
/** Response key=value text segment. */
data: Buffer
/** Login Response Status-Class (byte 36). Defaults to SUCCESS when omitted. */
statusClass?: number
/** Login Response Status-Detail (byte 37). Defaults to 0 when omitted. */
statusDetail?: number
}
/** A rejected login: the Status-Class/Detail bytes the response must carry. */
interface AuthFailure {
statusClass: number
statusDetail: number
}
/** CHAP authentication failure (RFC 7143 §11.1.5): Initiator Error / auth failure. */
const AUTH_FAILURE: AuthFailure = {
statusClass: LoginStatusClass.INITIATOR_ERROR,
statusDetail: LoginStatusDetail.AUTHENTICATION_FAILURE,
}
/** Progress of the target-side one-way CHAP exchange (RFC 7143 §11.1.4). */
type ChapState = 'none' | 'awaitAlgorithm' | 'awaitResponse' | 'authenticated' | 'failed'
/**
* Negotiates one connection's login: the target is authoritative, so every
* key is answered with the pinned value, and RFC 7143's boolean rules
* guarantee the session collapses to the one supported path regardless of
* what the initiator offers.
*
* With {@link ChapCredentials}, the target challenges the initiator during
* security negotiation and refuses to reach the operational stage until it
* proves the secret; without one, it uses the legacy `AuthMethod=None` path.
*/
export class LoginNegotiator {
#firstResponseSent = false
#complete = false
readonly #chap?: ChapCredentials
#chapState: ChapState = 'none'
#chapId = 0
#chapChallenge: Buffer = EMPTY
sessionType: SessionType = 'Normal'
initiatorName?: string
targetName?: string
readonly params: NegotiatedParams = {
initiatorMaxRecvDataSegmentLength: DEFAULT_INITIATOR_MAX_RECV_DATA_SEGMENT_LENGTH,
maxBurstLength: OUR_MAX_BURST_LENGTH,
}
constructor(chap?: ChapCredentials) {
this.#chap = chap
}
/** True once the session has transited to the full-feature phase. */
get complete(): boolean {
return this.#complete
}
/** True once CHAP authentication has definitively failed for this connection. */
get failed(): boolean {
return this.#chapState === 'failed'
}
/** CHAP is enforced only when configured and only for Normal (data) sessions. */
get #chapRequired(): boolean {
return this.#chap !== undefined && this.sessionType === 'Normal'
}
process(pdu: IncomingPdu): LoginStepResult {
const flags = pdu.flags
const csg = (flags & LOGIN_CSG_MASK) >> LOGIN_CSG_SHIFT
const nsg = flags & LOGIN_NSG_MASK
const transit = (flags & LOGIN_FLAG_TRANSIT) !== 0
const cont = (flags & LOGIN_FLAG_CONTINUE) !== 0
const keys = parseTextKeys(pdu.data)
log.debug('login request', { csg, nsg, transit, cont, keys: Object.fromEntries(keys) })
const sessionType = keys.get('SessionType')
if (sessionType !== undefined) {
this.sessionType = sessionType === 'Discovery' ? 'Discovery' : 'Normal'
}
this.initiatorName ??= keys.get('InitiatorName')
this.targetName ??= keys.get('TargetName')
const responseKeys: Array<[string, string]> = []
// A normal session's first Login Response must carry the portal group tag.
if (!this.#firstResponseSent && this.sessionType === 'Normal') {
responseKeys.push(['TargetPortalGroupTag', '1'])
}
let failure: AuthFailure | undefined
if (csg === LoginStage.SECURITY_NEGOTIATION) {
// Act only on a complete key set (C bit clear); while the initiator is
// still sending keys we accumulate without answering the exchange.
if (!cont) {
failure = this.#handleSecurity(keys, responseKeys)
}
} else if (csg === LoginStage.OPERATIONAL_NEGOTIATION) {
if (this.#chapRequired && this.#chapState !== 'authenticated') {
// Normal session jumped to operational negotiation without completing the
// CHAP security stage we require.
this.#chapState = 'failed'
failure = AUTH_FAILURE
} else {
this.#negotiateOperational(keys, responseKeys)
}
}
let flagsByte = csg << LOGIN_CSG_SHIFT
// Transit only when the initiator asked to, is not still sending keys, and
// (if CHAP is required) authentication has succeeded. This single gate blocks
// a premature Transit bit and any transit before/without CHAP.
const authReady = !this.#chapRequired || this.#chapState === 'authenticated'
if (transit && !cont && authReady) {
flagsByte |= LOGIN_FLAG_TRANSIT | nsg
if (nsg === LoginStage.FULL_FEATURE_PHASE) {
this.#complete = true
}
}
this.#firstResponseSent = true
log.debug('login response', {
transit: (flagsByte & LOGIN_FLAG_TRANSIT) !== 0,
nsg: flagsByte & LOGIN_NSG_MASK,
keys: Object.fromEntries(responseKeys),
statusClass: failure?.statusClass ?? 0,
statusDetail: failure?.statusDetail ?? 0,
chapState: this.#chapState,
})
return {
flagsByte,
data: serializeTextKeys(responseKeys),
statusClass: failure?.statusClass,
statusDetail: failure?.statusDetail,
}
}
/**
* Drive one round of the target-side one-way CHAP exchange, mutating
* {@link ChapState} and appending any response keys. Returns an
* {@link AuthFailure} when authentication is rejected, otherwise `undefined`
* (success or "still in progress, awaiting the next PDU").
*/
#handleSecurity(keys: Map<string, string>, responseKeys: Array<[string, string]>): AuthFailure | undefined {
// CHAP applies only to Normal (data) sessions. Discovery sessions can only
// run SendTargets — they never reach the LUN — so they negotiate
// AuthMethod=None, keeping the standard unauthenticated `iscsiadm -m
// discovery` flow working. Without CHAP configured, this is the only path.
const chap = this.#chap
if (chap === undefined || this.sessionType !== 'Normal') {
if (keys.has('AuthMethod')) {
responseKeys.push(['AuthMethod', AuthMethod.NONE])
}
return undefined
}
switch (this.#chapState) {
case 'none': {
const offer = keys.get('AuthMethod')
if (offer === undefined) {
return undefined // still waiting for the AuthMethod offer
}
if (!offer.split(',').includes(AuthMethod.CHAP)) {
// Initiator offered only None (or an unknown method) while we require CHAP.
log.debug('chap: initiator did not offer CHAP', { offered: offer })
this.#chapState = 'failed'
return AUTH_FAILURE
}
responseKeys.push(['AuthMethod', AuthMethod.CHAP])
this.#chapState = 'awaitAlgorithm'
return undefined
}
case 'awaitAlgorithm': {
const offered = keys.get('CHAP_A')
if (offered === undefined) {
return undefined // still waiting for CHAP_A
}
const algorithm = selectAlgorithm(parseAlgorithmList(offered))
if (algorithm === undefined) {
log.debug('chap: no supported algorithm offered', { offered })
this.#chapState = 'failed'
return AUTH_FAILURE
}
this.#chapId = generateId()
this.#chapChallenge = generateChallenge()
responseKeys.push(['CHAP_A', String(algorithm)])
responseKeys.push(['CHAP_I', String(this.#chapId)])
responseKeys.push(['CHAP_C', encodeChapValue(this.#chapChallenge)])
log.debug('chap: issued challenge', { algorithm, id: this.#chapId })
this.#chapState = 'awaitResponse'
return undefined
}
case 'awaitResponse': {
const name = keys.get('CHAP_N')
const response = keys.get('CHAP_R')
if (name === undefined || response === undefined) {
return undefined // still waiting for CHAP_N / CHAP_R
}
let got: Buffer
try {
got = decodeChapValue(response)
} catch {
this.#chapState = 'failed'
return AUTH_FAILURE
}
const expected = computeResponse(this.#chapId, chap.secret, this.#chapChallenge)
if (name === chap.user && verifyResponse(expected, got)) {
log.debug('chap: authenticated', { user: name })
this.#chapState = 'authenticated'
return undefined
}
log.debug('chap: verification failed', { user: name, userMatch: name === chap.user })
this.#chapState = 'failed'
return AUTH_FAILURE
}
default:
return undefined
}
}
#negotiateOperational(keys: Map<string, string>, responseKeys: Array<[string, string]>): void {
// Only answer what the initiator actually offered: for every key below, the
// RFC 7143 default of an unoffered key is already the value we want, so
// staying silent and letting the default stand is equivalent.
const answer = (key: string, value: string) => {
if (keys.has(key)) {
responseKeys.push([key, value])
}
}
answer('HeaderDigest', 'None')
answer('DataDigest', 'None')
answer('MaxConnections', '1')
answer('InitialR2T', 'Yes')
answer('ErrorRecoveryLevel', '0')
// ImmediateData is the one exception: its RFC 7143 default is *Yes*, so an
// initiator that never offers the key may legally put write data in the SCSI
// Command PDU itself — which this target does not implement and would
// otherwise silently drop, acknowledging a write it never applied. Declare
// No whether or not it was offered. Costs nothing: there is no immediate
// data path to lose, and every mainstream initiator offers the key anyway,
// so for them this is byte-identical to before.
responseKeys.push(['ImmediateData', 'No'])
answer('DataPDUInOrder', 'Yes')
answer('DataSequenceInOrder', 'Yes')
answer('DefaultTime2Wait', '2')
answer('DefaultTime2Retain', '0')
answer('MaxOutstandingR2T', '1')
// MaxRecvDataSegmentLength is declarative per direction: capture the
// initiator's (caps our Data-In) and always declare ours (caps its Data-Out).
this.params.initiatorMaxRecvDataSegmentLength = parsePositiveInt(
keys.get('MaxRecvDataSegmentLength'),
this.params.initiatorMaxRecvDataSegmentLength
)
responseKeys.push(['MaxRecvDataSegmentLength', String(OUR_MAX_RECV_DATA_SEGMENT_LENGTH)])
// MaxBurstLength / FirstBurstLength negotiate to the minimum of both offers.
const maxBurst = Math.min(parsePositiveInt(keys.get('MaxBurstLength'), OUR_MAX_BURST_LENGTH), OUR_MAX_BURST_LENGTH)
this.params.maxBurstLength = maxBurst
if (keys.has('MaxBurstLength')) {
responseKeys.push(['MaxBurstLength', String(maxBurst)])
}
if (keys.has('FirstBurstLength')) {
const firstBurst = Math.min(parsePositiveInt(keys.get('FirstBurstLength'), maxBurst), maxBurst)
responseKeys.push(['FirstBurstLength', String(firstBurst)])
}
}
}

View File

@@ -0,0 +1,313 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
InitiatorOpcode,
LOGIN_CSG_SHIFT,
LOGIN_FLAG_CONTINUE,
LOGIN_FLAG_TRANSIT,
LOGIN_NSG_MASK,
LoginStage,
LoginStatusClass,
LoginStatusDetail,
OPCODE_IMMEDIATE,
} from './constants.mjs'
import { buildSendTargetsResponse, formatPortal, LoginNegotiator, parseTextKeys, serializeTextKeys } from './login.mjs'
import { computeResponse, decodeChapValue, encodeChapValue } from './chap.mjs'
import { IncomingPdu } from './pdu.mjs'
function loginFlags(options: { transit?: boolean; cont?: boolean; csg: number; nsg: number }): number {
return (
(options.transit ? LOGIN_FLAG_TRANSIT : 0) |
(options.cont ? LOGIN_FLAG_CONTINUE : 0) |
(options.csg << LOGIN_CSG_SHIFT) |
(options.nsg & LOGIN_NSG_MASK)
)
}
function makeLoginPdu(flags: number, keys: Record<string, string>): IncomingPdu {
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.LOGIN_REQUEST | OPCODE_IMMEDIATE
bhs[1] = flags
const data = serializeTextKeys(Object.entries(keys))
return new IncomingPdu(bhs, Buffer.alloc(0), data)
}
describe('text key serialization', () => {
it('round-trips NUL-delimited key=value pairs', () => {
const buffer = serializeTextKeys([
['A', '1'],
['HeaderDigest', 'None'],
])
assert.equal(buffer.toString('binary'), 'A=1\0HeaderDigest=None\0')
const keys = parseTextKeys(buffer)
assert.equal(keys.get('A'), '1')
assert.equal(keys.get('HeaderDigest'), 'None')
})
it('ignores empty fragments and malformed pairs', () => {
const keys = parseTextKeys(Buffer.from('\0Key=Value\0bogus\0', 'binary'))
assert.equal(keys.size, 1)
assert.equal(keys.get('Key'), 'Value')
})
})
describe('LoginNegotiator', () => {
it('completes a single-PDU operational login and pins the negotiated values', () => {
const negotiator = new LoginNegotiator()
const pdu = makeLoginPdu(
loginFlags({
transit: true,
csg: LoginStage.OPERATIONAL_NEGOTIATION,
nsg: LoginStage.FULL_FEATURE_PHASE,
}),
{
InitiatorName: 'iqn.1994-05.com.example:initiator',
SessionType: 'Normal',
HeaderDigest: 'None,CRC32C',
DataDigest: 'None,CRC32C',
InitialR2T: 'No',
ImmediateData: 'Yes',
MaxRecvDataSegmentLength: '262144',
MaxBurstLength: '16776192',
}
)
const { flagsByte, data } = negotiator.process(pdu)
assert.equal(negotiator.complete, true)
assert.ok((flagsByte & LOGIN_FLAG_TRANSIT) !== 0)
assert.equal(flagsByte & LOGIN_NSG_MASK, LoginStage.FULL_FEATURE_PHASE)
const keys = parseTextKeys(data)
assert.equal(keys.get('TargetPortalGroupTag'), '1')
assert.equal(keys.get('HeaderDigest'), 'None')
assert.equal(keys.get('DataDigest'), 'None')
// Pinned to the single write code path regardless of the initiator's offer.
assert.equal(keys.get('InitialR2T'), 'Yes')
assert.equal(keys.get('ImmediateData'), 'No')
// MaxBurstLength negotiates down to our cap.
assert.equal(keys.get('MaxBurstLength'), '262144')
assert.equal(negotiator.sessionType, 'Normal')
assert.equal(negotiator.initiatorName, 'iqn.1994-05.com.example:initiator')
assert.equal(negotiator.params.initiatorMaxRecvDataSegmentLength, 262144)
assert.equal(negotiator.params.maxBurstLength, 262144)
})
it('handles a two-stage security then operational login', () => {
const negotiator = new LoginNegotiator()
const security = negotiator.process(
makeLoginPdu(
loginFlags({
transit: true,
csg: LoginStage.SECURITY_NEGOTIATION,
nsg: LoginStage.OPERATIONAL_NEGOTIATION,
}),
{ InitiatorName: 'iqn.x:i', SessionType: 'Normal', AuthMethod: 'None' }
)
)
assert.equal(negotiator.complete, false)
assert.equal(parseTextKeys(security.data).get('AuthMethod'), 'None')
const operational = negotiator.process(
makeLoginPdu(
loginFlags({
transit: true,
csg: LoginStage.OPERATIONAL_NEGOTIATION,
nsg: LoginStage.FULL_FEATURE_PHASE,
}),
{ HeaderDigest: 'None', DataDigest: 'None' }
)
)
assert.equal(negotiator.complete, true)
// The portal group tag is only emitted in the first response.
assert.equal(parseTextKeys(operational.data).get('TargetPortalGroupTag'), undefined)
})
it('omits the portal group tag for a discovery session', () => {
const negotiator = new LoginNegotiator()
const { data } = negotiator.process(
makeLoginPdu(
loginFlags({
transit: true,
csg: LoginStage.OPERATIONAL_NEGOTIATION,
nsg: LoginStage.FULL_FEATURE_PHASE,
}),
{ SessionType: 'Discovery' }
)
)
assert.equal(negotiator.sessionType, 'Discovery')
assert.equal(parseTextKeys(data).get('TargetPortalGroupTag'), undefined)
})
it('does not transit while the initiator is still sending keys (C bit)', () => {
const negotiator = new LoginNegotiator()
const { flagsByte } = negotiator.process(
makeLoginPdu(
loginFlags({
transit: true,
cont: true,
csg: LoginStage.OPERATIONAL_NEGOTIATION,
nsg: LoginStage.FULL_FEATURE_PHASE,
}),
{ HeaderDigest: 'None' }
)
)
assert.equal(negotiator.complete, false)
assert.equal(flagsByte & LOGIN_FLAG_TRANSIT, 0)
})
})
describe('LoginNegotiator CHAP authenticator', () => {
const CHAP = { user: 'alice', secret: 's3cr3t' }
const securityFlags = (transit = false) =>
loginFlags({ transit, csg: LoginStage.SECURITY_NEGOTIATION, nsg: LoginStage.OPERATIONAL_NEGOTIATION })
/** Round 1: offer AuthMethod. */
function offerAuth(negotiator: LoginNegotiator, methods = 'CHAP,None', transit = false) {
return negotiator.process(
makeLoginPdu(securityFlags(transit), { InitiatorName: 'iqn.x:i', SessionType: 'Normal', AuthMethod: methods })
)
}
/** Round 2: offer CHAP_A, return the target's parsed challenge keys. */
function offerAlgorithm(negotiator: LoginNegotiator, list = '5') {
const result = negotiator.process(makeLoginPdu(securityFlags(false), { CHAP_A: list }))
return { result, keys: parseTextKeys(result.data) }
}
/** Round 3: answer with CHAP_N/CHAP_R for the given challenge. */
function answerChallenge(
negotiator: LoginNegotiator,
challengeKeys: Map<string, string>,
{ user = CHAP.user, secret = CHAP.secret } = {}
) {
const id = Number.parseInt(challengeKeys.get('CHAP_I') ?? '', 10)
const challenge = decodeChapValue(challengeKeys.get('CHAP_C') ?? '')
const response = encodeChapValue(computeResponse(id, secret, challenge))
return negotiator.process(makeLoginPdu(securityFlags(true), { CHAP_N: user, CHAP_R: response }))
}
it('challenges then transits only after a correct response', () => {
const negotiator = new LoginNegotiator(CHAP)
// Round 1: we answer AuthMethod=CHAP and do NOT transit.
const r1 = offerAuth(negotiator)
assert.equal(parseTextKeys(r1.data).get('AuthMethod'), 'CHAP')
assert.equal(r1.flagsByte & LOGIN_FLAG_TRANSIT, 0)
assert.equal(r1.statusClass ?? LoginStatusClass.SUCCESS, LoginStatusClass.SUCCESS)
// Round 2: we issue CHAP_A=5, an identifier and a challenge, still no transit.
const { result: r2, keys: challenge } = offerAlgorithm(negotiator)
assert.equal(challenge.get('CHAP_A'), '5')
assert.ok(challenge.has('CHAP_I'))
assert.match(challenge.get('CHAP_C') ?? '', /^0x[0-9a-f]+$/)
assert.equal(r2.flagsByte & LOGIN_FLAG_TRANSIT, 0)
// Round 3: a correct response transits to the operational stage.
const r3 = answerChallenge(negotiator, challenge)
assert.equal(r3.statusClass ?? LoginStatusClass.SUCCESS, LoginStatusClass.SUCCESS)
assert.ok((r3.flagsByte & LOGIN_FLAG_TRANSIT) !== 0, 'transit granted after auth')
assert.equal(r3.flagsByte & LOGIN_NSG_MASK, LoginStage.OPERATIONAL_NEGOTIATION)
assert.equal(negotiator.failed, false)
// The operational stage then completes as usual.
const op = negotiator.process(
makeLoginPdu(
loginFlags({ transit: true, csg: LoginStage.OPERATIONAL_NEGOTIATION, nsg: LoginStage.FULL_FEATURE_PHASE }),
{ HeaderDigest: 'None', DataDigest: 'None' }
)
)
assert.equal(negotiator.complete, true)
assert.equal(parseTextKeys(op.data).get('HeaderDigest'), 'None')
})
it('rejects a wrong secret with Initiator Error / authentication failure', () => {
const negotiator = new LoginNegotiator(CHAP)
offerAuth(negotiator)
const { keys: challenge } = offerAlgorithm(negotiator)
const r3 = answerChallenge(negotiator, challenge, { secret: 'wrong' })
assert.equal(r3.statusClass, LoginStatusClass.INITIATOR_ERROR)
assert.equal(r3.statusDetail, LoginStatusDetail.AUTHENTICATION_FAILURE)
assert.equal(r3.flagsByte & LOGIN_FLAG_TRANSIT, 0)
assert.equal(negotiator.complete, false)
assert.equal(negotiator.failed, true)
})
it('rejects a wrong username even with the right secret', () => {
const negotiator = new LoginNegotiator(CHAP)
offerAuth(negotiator)
const { keys: challenge } = offerAlgorithm(negotiator)
const r3 = answerChallenge(negotiator, challenge, { user: 'mallory' })
assert.equal(r3.statusClass, LoginStatusClass.INITIATOR_ERROR)
assert.equal(negotiator.failed, true)
})
it('fails when the initiator offers only AuthMethod=None', () => {
const negotiator = new LoginNegotiator(CHAP)
const r1 = offerAuth(negotiator, 'None')
assert.equal(r1.statusClass, LoginStatusClass.INITIATOR_ERROR)
assert.equal(r1.statusDetail, LoginStatusDetail.AUTHENTICATION_FAILURE)
assert.equal(negotiator.failed, true)
})
it('fails when CHAP_A does not offer MD5', () => {
const negotiator = new LoginNegotiator(CHAP)
offerAuth(negotiator)
const { result } = offerAlgorithm(negotiator, '6,7')
assert.equal(result.statusClass, LoginStatusClass.INITIATOR_ERROR)
assert.equal(negotiator.failed, true)
})
it('does not transit if the initiator sets the T bit before authenticating', () => {
const negotiator = new LoginNegotiator(CHAP)
// Round 1 with the Transit bit set: must be ignored until CHAP completes.
const r1 = offerAuth(negotiator, 'CHAP,None', true)
assert.equal(r1.flagsByte & LOGIN_FLAG_TRANSIT, 0)
assert.equal(negotiator.complete, false)
})
it('fails if the initiator skips security and jumps to operational negotiation', () => {
const negotiator = new LoginNegotiator(CHAP)
const result = negotiator.process(
makeLoginPdu(
loginFlags({ transit: true, csg: LoginStage.OPERATIONAL_NEGOTIATION, nsg: LoginStage.FULL_FEATURE_PHASE }),
{ HeaderDigest: 'None' }
)
)
assert.equal(result.statusClass, LoginStatusClass.INITIATOR_ERROR)
assert.equal(result.flagsByte & LOGIN_FLAG_TRANSIT, 0)
assert.equal(negotiator.complete, false)
assert.equal(negotiator.failed, true)
})
})
describe('formatPortal', () => {
it('keeps plain IPv4 addresses', () => {
assert.equal(formatPortal('10.0.0.1', 3260), '10.0.0.1:3260,1')
})
it('unwraps IPv4-mapped IPv6 (dual-stack socket artifact)', () => {
assert.equal(formatPortal('::ffff:192.168.1.8', 3260), '192.168.1.8:3260,1')
})
it('brackets real IPv6 literals', () => {
assert.equal(formatPortal('2a01:e0a:e6a:9500::1', 3260), '[2a01:e0a:e6a:9500::1]:3260,1')
})
})
describe('buildSendTargetsResponse', () => {
it('advertises the target name and address', () => {
const keys = parseTextKeys(buildSendTargetsResponse('iqn.2024-01.tech.vates:lun0', '10.0.0.1', 3260))
assert.equal(keys.get('TargetName'), 'iqn.2024-01.tech.vates:lun0')
assert.equal(keys.get('TargetAddress'), '10.0.0.1:3260,1')
})
it('advertises a clean IPv4 portal for an IPv4-mapped local address', () => {
const keys = parseTextKeys(buildSendTargetsResponse('iqn.x:y', '::ffff:192.168.1.8', 3260))
assert.equal(keys.get('TargetAddress'), '192.168.1.8:3260,1')
})
})

File diff suppressed because it is too large Load Diff

174
@vates/iscsi/src/pdu.mts Normal file
View File

@@ -0,0 +1,174 @@
import type { Socket } from 'node:net'
import type { Readable } from 'node:stream'
import { readChunk, readChunkStrict } from '@vates/read-chunk'
import { pTimeout } from 'promise-toolbox'
import { createLogger, type Logger } from '@xen-orchestra/log'
import { BHS_LENGTH, opcodeName, OPCODE_IMMEDIATE, OPCODE_MASK, FLAG_FINAL } from './constants.mjs'
const EMPTY = Buffer.alloc(0)
// Framing-level wire trace, shared by target and initiator. Enable with the
// `vates:iscsi:pdu` debug namespace to see every PDU that crosses the socket —
// the first thing to look at when a new initiator/target combination misbehaves.
const log: Logger = createLogger('vates:iscsi:pdu')
/** Round `n` up to the next multiple of 4 (iSCSI segments are 4-byte padded). */
export function roundUp4(n: number): number {
return (n + 3) & ~3
}
/**
* A fully-received inbound PDU: the 48-byte Basic Header Segment, any Additional
* Header Segment, and the (unpadded) data segment. Field accessors read the BHS
* at the offsets defined by RFC 7143; callers pick the ones relevant to the
* opcode they are handling.
*/
export class IncomingPdu {
constructor(
readonly bhs: Buffer,
readonly ahs: Buffer,
readonly data: Buffer
) {}
/** 6-bit opcode (immediate bit masked off). */
get opcode(): number {
return this.bhs[0] & OPCODE_MASK
}
/** Whether the immediate bit is set (initiator requests immediate processing). */
get immediate(): boolean {
return (this.bhs[0] & OPCODE_IMMEDIATE) !== 0
}
/** Raw opcode-specific flags byte (byte 1). */
get flags(): number {
return this.bhs[1]
}
get finalBit(): boolean {
return (this.bhs[1] & FLAG_FINAL) !== 0
}
readU8(offset: number): number {
return this.bhs.readUInt8(offset)
}
readU16(offset: number): number {
return this.bhs.readUInt16BE(offset)
}
readU32(offset: number): number {
return this.bhs.readUInt32BE(offset)
}
/** Initiator Task Tag (bytes 16-19), present in every initiator PDU we handle. */
get itt(): number {
return this.bhs.readUInt32BE(16)
}
/** CmdSN (bytes 24-27) — meaningful on command-carrying PDUs. */
get cmdSN(): number {
return this.bhs.readUInt32BE(24)
}
/** ExpStatSN (bytes 28-31). */
get expStatSN(): number {
return this.bhs.readUInt32BE(28)
}
}
/**
* Read exactly one PDU from `stream`, reassembling across TCP boundaries.
* Resolves to `null` on a clean EOF between PDUs; throws on a truncated PDU
* or read error. No header/data digest bytes: digests are pinned off.
*
* No per-read timeout, intentionally — a target may sit idle between commands
* indefinitely. Liveness is a transport concern (socket timeout / NOP
* keepalives), handled elsewhere.
*
* A PDU announcing a data segment longer than `maxDataSegmentLength` is
* rejected before its payload is read, so a peer cannot make us buffer up to
* the 16 MiB the 24-bit length field allows.
*/
export async function readPdu(stream: Readable, maxDataSegmentLength = Infinity): Promise<IncomingPdu | null> {
const bhs = await readChunk(stream, BHS_LENGTH)
if (bhs === null) {
return null // clean EOF between PDUs
}
if (bhs.length !== BHS_LENGTH) {
throw new Error(`truncated iSCSI BHS (got ${bhs.length} of ${BHS_LENGTH} bytes)`)
}
const totalAhsLength = bhs.readUInt8(4)
const dataSegmentLength = bhs.readUIntBE(5, 3)
if (dataSegmentLength > maxDataSegmentLength) {
throw new Error(`iSCSI data segment too long (${dataSegmentLength} > ${maxDataSegmentLength} bytes)`)
}
const ahsBytes = totalAhsLength * 4
const dataBytesPadded = roundUp4(dataSegmentLength)
let ahs = EMPTY
let data = EMPTY
if (ahsBytes + dataBytesPadded > 0) {
const rest = await readChunkStrict(stream, ahsBytes + dataBytesPadded)
if (ahsBytes > 0) {
ahs = rest.subarray(0, ahsBytes)
}
data = rest.subarray(ahsBytes, ahsBytes + dataSegmentLength) // strip 4-byte padding
}
const pdu = new IncomingPdu(bhs, ahs, data)
log.debug('rx', {
opcode: opcodeName(bhs[0]),
itt: pdu.itt,
flags: `0x${bhs[1].toString(16).padStart(2, '0')}`,
ahs: ahsBytes,
data: dataSegmentLength,
})
return pdu
}
/** Allocate a zeroed 48-byte BHS with byte 0 set to `opcode`. */
export function allocBhs(opcode: number): Buffer {
const bhs = Buffer.alloc(BHS_LENGTH)
bhs[0] = opcode
return bhs
}
/** Write the 24-bit DataSegmentLength field (bytes 5-7) of a BHS. */
export function setDataSegmentLength(bhs: Buffer, length: number): void {
bhs.writeUIntBE(length, 5, 3)
}
/**
* Assemble a complete PDU buffer from a 48-byte BHS and an optional data
* segment, setting DataSegmentLength and applying 4-byte padding.
*/
export function assemblePdu(bhs: Buffer, data?: Buffer): Buffer {
const length = data?.length ?? 0
setDataSegmentLength(bhs, length)
if (length === 0) {
return bhs
}
const padding = roundUp4(length) - length
return padding === 0 ? Buffer.concat([bhs, data!]) : Buffer.concat([bhs, data!, Buffer.alloc(padding)])
}
/**
* Write a PDU to the socket, resolving once the kernel has accepted it (honoring
* back-pressure via the write callback). Rejects if the write errors or does not
* drain within `timeoutMs` (0 disables the timeout).
*/
export function writePdu(socket: Socket, buffer: Buffer, timeoutMs: number): Promise<void> {
log.debug('tx', {
opcode: opcodeName(buffer[0]),
itt: buffer.readUInt32BE(16),
flags: `0x${buffer[1].toString(16).padStart(2, '0')}`,
length: buffer.length,
})
const promise = new Promise<void>((resolve, reject) => {
socket.write(buffer, error => (error ? reject(error) : resolve()))
})
return timeoutMs > 0 ? pTimeout.call(promise, timeoutMs) : promise
}

View File

@@ -0,0 +1,118 @@
import assert from 'node:assert/strict'
import { PassThrough } from 'node:stream'
import { describe, it } from 'node:test'
import { InitiatorOpcode } from './constants.mjs'
import { allocBhs, assemblePdu, IncomingPdu, readPdu, roundUp4 } from './pdu.mjs'
function buildScsiCommandPdu(itt: number, cmdSN: number, cdb: Buffer, data?: Buffer): Buffer {
const bhs = allocBhs(InitiatorOpcode.SCSI_COMMAND)
bhs[1] = 0x80 // Final
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(0, 20) // EDTL
bhs.writeUInt32BE(cmdSN, 24)
cdb.copy(bhs, 32)
return assemblePdu(bhs, data)
}
async function readOne(buffer: Buffer): Promise<IncomingPdu> {
const stream = new PassThrough()
stream.end(buffer)
const pdu = await readPdu(stream)
assert.ok(pdu !== null)
return pdu
}
describe('roundUp4', () => {
it('rounds up to the next multiple of 4', () => {
assert.equal(roundUp4(0), 0)
assert.equal(roundUp4(1), 4)
assert.equal(roundUp4(4), 4)
assert.equal(roundUp4(5), 8)
assert.equal(roundUp4(15), 16)
})
})
describe('assemblePdu / readPdu round-trip', () => {
it('preserves opcode and header fields', async () => {
const cdb = Buffer.alloc(16)
cdb[0] = 0x28 // READ(10)
const pdu = await readOne(buildScsiCommandPdu(0xdeadbeef, 42, cdb))
assert.equal(pdu.opcode, InitiatorOpcode.SCSI_COMMAND)
assert.equal(pdu.immediate, false)
assert.equal(pdu.finalBit, true)
assert.equal(pdu.itt, 0xdeadbeef)
assert.equal(pdu.cmdSN, 42)
assert.equal(pdu.bhs.subarray(32, 48)[0], 0x28)
})
it('carries a data segment and strips its 4-byte padding', async () => {
const data = Buffer.from([1, 2, 3, 4, 5]) // 5 bytes -> padded to 8 on the wire
const wire = buildScsiCommandPdu(1, 1, Buffer.alloc(16), data)
assert.equal(wire.length, 48 + 8, 'data segment is padded to a 4-byte boundary')
const pdu = await readOne(wire)
assert.deepEqual(pdu.data, data, 'padding is stripped from the parsed data')
})
})
describe('readPdu framing', () => {
it('rejects a data segment longer than the limit before reading it', async () => {
const bhs = allocBhs(InitiatorOpcode.SCSI_COMMAND)
bhs.writeUIntBE(16 * 1024 * 1024 - 1, 5, 3) // announced data segment length
const stream = new PassThrough()
stream.write(bhs) // the announced payload is never sent
await assert.rejects(readPdu(stream, 8192), /data segment too long/)
})
it('accepts a data segment of exactly the limit', async () => {
const data = Buffer.alloc(8192, 0x5a)
const pdu = await (async () => {
const stream = new PassThrough()
stream.end(buildScsiCommandPdu(1, 1, Buffer.alloc(16), data))
return readPdu(stream, 8192)
})()
assert.deepEqual(pdu?.data, data)
})
it('reassembles a PDU split across multiple TCP chunks', async () => {
const data = Buffer.from('hello world!!') // 13 bytes -> padded to 16
const wire = buildScsiCommandPdu(7, 7, Buffer.alloc(16), data)
const stream = new PassThrough()
const pduPromise = readPdu(stream)
// Feed the bytes in awkward fragments.
stream.write(wire.subarray(0, 10))
stream.write(wire.subarray(10, 48))
stream.write(wire.subarray(48, 55))
stream.end(wire.subarray(55))
const pdu = await pduPromise
assert.ok(pdu !== null)
assert.equal(pdu.itt, 7)
assert.deepEqual(pdu.data, data)
})
it('reads two PDUs coalesced into one chunk', async () => {
const first = buildScsiCommandPdu(1, 1, Buffer.alloc(16), Buffer.from([0xaa]))
const second = buildScsiCommandPdu(2, 2, Buffer.alloc(16), Buffer.from([0xbb, 0xcc]))
const stream = new PassThrough()
stream.end(Buffer.concat([first, second]))
const a = await readPdu(stream)
const b = await readPdu(stream)
assert.equal(a?.itt, 1)
assert.deepEqual(a?.data, Buffer.from([0xaa]))
assert.equal(b?.itt, 2)
assert.deepEqual(b?.data, Buffer.from([0xbb, 0xcc]))
})
it('returns null on a clean end-of-stream', async () => {
const stream = new PassThrough()
stream.end()
assert.equal(await readPdu(stream), null)
})
it('throws on a truncated BHS', async () => {
const stream = new PassThrough()
stream.end(Buffer.alloc(20)) // fewer than 48 bytes, then EOF
await assert.rejects(readPdu(stream), /truncated/)
})
})

293
@vates/iscsi/src/scsi.mts Normal file
View File

@@ -0,0 +1,293 @@
import { createLogger, type Logger } from '@xen-orchestra/log'
import { Asc, ScsiStatus, SenseKey } from './constants.mjs'
import { decodeCdb, type CommandContext, type SenseInfo } from './types.mjs'
// SCSI command trace. Enable `vates:iscsi:scsi` to see every decoded CDB the
// target services — useful when an initiator issues a command we mishandle.
const log: Logger = createLogger('vates:iscsi:scsi')
/** SCSI INQUIRY identity strings advertised by the LUN. */
export interface ScsiIdentity {
/** T10 vendor identification (≤ 8 ASCII chars). */
vendor: string
/** Product identification (≤ 16 ASCII chars). */
product: string
/** Product revision level (≤ 4 ASCII chars). */
revision: string
/** Unit serial number; also seeds the VPD 0x83 device identifier. */
serial: string
}
// --- low-level encoders -----------------------------------------------------
function writeAscii(buffer: Buffer, offset: number, length: number, value: string): void {
buffer.fill(0x20, offset, offset + length) // space pad
buffer.write(value.slice(0, length), offset, 'ascii')
}
/** Standard INQUIRY data (36 bytes) for a direct-access block device. */
export function buildStandardInquiry(identity: ScsiIdentity): Buffer {
const buffer = Buffer.alloc(36)
buffer[0] = 0x00 // peripheral qualifier 0, device type 0x00 (direct-access block device)
buffer[1] = 0x00 // not removable
buffer[2] = 0x05 // version: SPC-3
buffer[3] = 0x02 // response data format 2
buffer[4] = 31 // additional length (total 36 - 5)
writeAscii(buffer, 8, 8, identity.vendor)
writeAscii(buffer, 16, 16, identity.product)
writeAscii(buffer, 32, 4, identity.revision)
return buffer
}
const SUPPORTED_VPD_PAGES = [0x00, 0x80, 0x83, 0xb0]
/** Default max length of a single READ or WRITE, in bytes. */
export const DEFAULT_MAX_TRANSFER_LENGTH = 8 * 1024 * 1024
/**
* Build an INQUIRY EVPD page, or `undefined` for an unsupported page code (the
* caller then returns CHECK CONDITION / INVALID FIELD IN CDB).
*/
export function buildVpd(
pageCode: number,
identity: ScsiIdentity,
maxTransferBlocks = DEFAULT_MAX_TRANSFER_LENGTH / 512
): Buffer | undefined {
switch (pageCode) {
case 0x00: {
// Supported VPD pages.
const buffer = Buffer.alloc(4 + SUPPORTED_VPD_PAGES.length)
buffer[1] = 0x00
buffer.writeUInt16BE(SUPPORTED_VPD_PAGES.length, 2)
SUPPORTED_VPD_PAGES.forEach((page, i) => {
buffer[4 + i] = page
})
return buffer
}
case 0x80: {
// Unit serial number.
const serial = Buffer.from(identity.serial, 'ascii')
const buffer = Buffer.alloc(4 + serial.length)
buffer[1] = 0x80
buffer.writeUInt16BE(serial.length, 2)
serial.copy(buffer, 4)
return buffer
}
case 0x83: {
// Device identification: one T10-vendor-ID-based designator (ASCII).
const designator = Buffer.alloc(8 + identity.serial.length)
writeAscii(designator, 0, 8, identity.vendor)
designator.write(identity.serial, 8, 'ascii')
const descriptor = Buffer.alloc(4 + designator.length)
descriptor[0] = 0x02 // code set: ASCII
descriptor[1] = 0x01 // association: logical unit, designator type: T10 vendor ID based
descriptor[3] = designator.length
designator.copy(descriptor, 4)
const buffer = Buffer.alloc(4 + descriptor.length)
buffer[1] = 0x83
buffer.writeUInt16BE(descriptor.length, 2)
descriptor.copy(buffer, 4)
return buffer
}
case 0xb0: {
// Block limits: initiators split their I/O to the max transfer length
// instead of seeing it refused.
const buffer = Buffer.alloc(64)
buffer[1] = 0xb0
buffer.writeUInt16BE(0x3c, 2)
buffer.writeUInt32BE(maxTransferBlocks, 8) // maximum transfer length
return buffer
}
default:
return undefined
}
}
/** READ CAPACITY(10) parameter data (8 bytes). */
export function buildReadCapacity10(blockCount: number, blockSize: number): Buffer {
const buffer = Buffer.alloc(8)
const maxLba = blockCount - 1
// If the capacity overflows 32 bits, return 0xffffffff to force the initiator
// to issue READ CAPACITY(16).
buffer.writeUInt32BE(maxLba > 0xffffffff ? 0xffffffff : maxLba, 0)
buffer.writeUInt32BE(blockSize, 4)
return buffer
}
/** READ CAPACITY(16) parameter data (32 bytes). */
export function buildReadCapacity16(blockCount: number, blockSize: number): Buffer {
const buffer = Buffer.alloc(32)
buffer.writeBigUInt64BE(BigInt(blockCount - 1), 0)
buffer.writeUInt32BE(blockSize, 8)
return buffer
}
/** REPORT LUNS parameter data advertising a single LUN 0 (16 bytes). */
export function buildReportLuns(): Buffer {
const buffer = Buffer.alloc(16)
buffer.writeUInt32BE(8, 0) // LUN list length: one 8-byte LUN entry
return buffer // bytes 8-15 are LUN 0 (all zero)
}
/** Fixed-format sense data (18 bytes). */
export function buildFixedSense({ key, asc, ascq }: SenseInfo): Buffer {
const buffer = Buffer.alloc(18)
buffer[0] = 0x70 // response code: current error, fixed format
buffer[2] = key
buffer[7] = 10 // additional sense length (18 - 8)
buffer[12] = asc
buffer[13] = ascq
return buffer
}
// WP bit of the device-specific parameter in the mode parameter header
const MODE_SENSE_WRITE_PROTECT = 0x80
/** Minimal MODE SENSE parameter data: a header with no block descriptor or pages. */
export function buildModeSense(long: boolean, writeProtected = false): Buffer {
const deviceSpecific = writeProtected ? MODE_SENSE_WRITE_PROTECT : 0
if (long) {
const buffer = Buffer.alloc(8)
buffer.writeUInt16BE(6, 0) // mode data length (8 - 2)
buffer[3] = deviceSpecific
return buffer
}
const buffer = Buffer.alloc(4)
buffer[0] = 3 // mode data length (4 - 1)
buffer[2] = deviceSpecific
return buffer
}
const ILLEGAL_REQUEST_INVALID_OPCODE: SenseInfo = {
key: SenseKey.ILLEGAL_REQUEST,
...Asc.INVALID_COMMAND_OPERATION_CODE,
}
const ILLEGAL_REQUEST_INVALID_FIELD: SenseInfo = {
key: SenseKey.ILLEGAL_REQUEST,
...Asc.INVALID_FIELD_IN_CDB,
}
const ILLEGAL_REQUEST_LBA_OUT_OF_RANGE: SenseInfo = {
key: SenseKey.ILLEGAL_REQUEST,
...Asc.LOGICAL_BLOCK_ADDRESS_OUT_OF_RANGE,
}
const MEDIUM_ERROR_NO_ADDITIONAL_SENSE: SenseInfo = { key: SenseKey.MEDIUM_ERROR, ...Asc.NO_ADDITIONAL_SENSE }
const DATA_PROTECT_WRITE_PROTECTED: SenseInfo = { key: SenseKey.DATA_PROTECT, ...Asc.WRITE_PROTECTED }
function checkCondition(ctx: CommandContext, itt: number, sense: SenseInfo): Promise<void> {
return ctx.sendScsiResponse(itt, ScsiStatus.CHECK_CONDITION, { sense: buildFixedSense(sense) })
}
/**
* Decode and execute one SCSI Command, driving completion through `ctx`.
*
* `cdb` is the 16-byte CDB from the SCSI Command PDU and `itt` its Initiator
* Task Tag. Read commands stream Data-In; WRITE solicits Data-Out via R2T;
* everything else returns status (with sense on error).
*
* A READ or WRITE longer than `maxTransferLength` bytes, advertised in the
* block limits VPD page, is refused with INVALID FIELD IN CDB: it also bounds
* the buffer a WRITE allocates.
*/
export async function handleScsiCommand(
cdb: Buffer,
itt: number,
ctx: CommandContext,
identity: ScsiIdentity,
maxTransferLength = DEFAULT_MAX_TRANSFER_LENGTH
): Promise<void> {
const { lun } = ctx
const blockSize = lun.getBlockSize()
const blockCount = lun.getSize() / blockSize
const maxTransferBlocks = Math.floor(maxTransferLength / blockSize)
const request = decodeCdb(cdb)
log.debug('scsi command', { itt, ...request })
switch (request.kind) {
case 'inquiry': {
if (!request.evpd) {
if (request.pageCode !== 0) {
// EVPD clear but a page code was given: invalid per SPC.
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
}
return ctx.sendReadData(itt, buildStandardInquiry(identity), request.allocationLength)
}
const page = buildVpd(request.pageCode, identity, maxTransferBlocks)
if (page === undefined) {
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
}
return ctx.sendReadData(itt, page, request.allocationLength)
}
case 'reportLuns':
return ctx.sendReadData(itt, buildReportLuns(), request.allocationLength)
case 'readCapacity10':
return ctx.sendReadData(itt, buildReadCapacity10(blockCount, blockSize), 8)
case 'readCapacity16':
return ctx.sendReadData(itt, buildReadCapacity16(blockCount, blockSize), request.allocationLength)
case 'read': {
if (request.lba + request.blocks > blockCount) {
return checkCondition(ctx, itt, ILLEGAL_REQUEST_LBA_OUT_OF_RANGE)
}
if (request.blocks > maxTransferBlocks) {
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
}
const byteLength = request.blocks * blockSize
if (byteLength === 0) {
return ctx.sendReadData(itt, Buffer.alloc(0), 0)
}
let data: Buffer
try {
data = await lun.read(request.lba * blockSize, byteLength)
} catch (error) {
// A single bad block (backend hiccup, corrupt source) must fail only
// this command, not the whole connection — mirrors how a failed
// lun.write() is handled in connection.mts's #handleDataOut.
log.warn('LUN read failed', error instanceof Error ? error : new Error(String(error)))
return checkCondition(ctx, itt, MEDIUM_ERROR_NO_ADDITIONAL_SENSE)
}
return ctx.sendReadData(itt, data, byteLength)
}
case 'write': {
if (lun.readOnly === true) {
return checkCondition(ctx, itt, DATA_PROTECT_WRITE_PROTECTED)
}
if (request.lba + request.blocks > blockCount) {
return checkCondition(ctx, itt, ILLEGAL_REQUEST_LBA_OUT_OF_RANGE)
}
if (request.blocks > maxTransferBlocks) {
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
}
const byteLength = request.blocks * blockSize
if (byteLength === 0) {
return ctx.sendScsiResponse(itt, ScsiStatus.GOOD)
}
// Hand off to the connection: it solicits the data via R2T and sends the
// SCSI Response once the (possibly interleaved) Data-Out PDUs complete.
return ctx.beginWrite(itt, request.lba * blockSize, byteLength)
}
case 'testUnitReady':
return ctx.sendScsiResponse(itt, ScsiStatus.GOOD)
case 'requestSense': {
// No deferred error model in v1: always report NO SENSE.
const sense = buildFixedSense({ key: SenseKey.NO_SENSE, ...Asc.NO_ADDITIONAL_SENSE })
return ctx.sendReadData(itt, sense, request.allocationLength)
}
case 'modeSense':
return ctx.sendReadData(itt, buildModeSense(request.long, lun.readOnly === true), request.allocationLength)
case 'syncCache':
await lun.flush()
return ctx.sendScsiResponse(itt, ScsiStatus.GOOD)
case 'unsupported':
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_OPCODE)
}
}

View File

@@ -0,0 +1,302 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import type { BlockDevice } from './backend.mjs'
import { ScsiStatus, SenseKey } from './constants.mjs'
import {
buildReadCapacity10,
buildReadCapacity16,
buildReportLuns,
buildStandardInquiry,
buildVpd,
handleScsiCommand,
type ScsiIdentity,
} from './scsi.mjs'
import { decodeCdb, type CommandContext, type ScsiResponseOptions } from './types.mjs'
const IDENTITY: ScsiIdentity = { vendor: 'VATES', product: 'ISCSI LUN', revision: '0001', serial: 'unit-serial-1' }
// --- a trivial in-memory LUN ------------------------------------------------
class MemoryBlockDevice implements BlockDevice {
readonly #buffer: Buffer
readonly #blockSize: number
constructor(sizeBytes: number, blockSize = 512) {
this.#buffer = Buffer.alloc(sizeBytes)
this.#blockSize = blockSize
}
get buffer(): Buffer {
return this.#buffer
}
getSize(): number {
return this.#buffer.length
}
getBlockSize(): number {
return this.#blockSize
}
async read(offset: number, length: number): Promise<Buffer> {
return Buffer.from(this.#buffer.subarray(offset, offset + length))
}
async write(offset: number, data: Buffer): Promise<void> {
data.copy(this.#buffer, offset)
}
async flush(): Promise<void> {}
async close(): Promise<void> {}
}
class FakeContext implements CommandContext {
readonly reads: Array<{ data: Buffer; allocationLength: number }> = []
readonly responses: Array<{ status: number; options?: ScsiResponseOptions }> = []
readonly writes: Array<{ itt: number; lunOffset: number; totalLength: number }> = []
constructor(readonly lun: BlockDevice) {}
async sendReadData(_itt: number, data: Buffer, allocationLength: number): Promise<void> {
this.reads.push({ data, allocationLength })
}
async beginWrite(itt: number, lunOffset: number, totalLength: number): Promise<void> {
this.writes.push({ itt, lunOffset, totalLength })
}
async sendScsiResponse(_itt: number, status: number, options?: ScsiResponseOptions): Promise<void> {
this.responses.push({ status, options })
}
}
function cdb(...bytes: number[]): Buffer {
const buffer = Buffer.alloc(16)
Buffer.from(bytes).copy(buffer)
return buffer
}
describe('decodeCdb', () => {
it('decodes READ(10) LBA and transfer length', () => {
const buffer = Buffer.alloc(16)
buffer[0] = 0x28
buffer.writeUInt32BE(0x01020304, 2)
buffer.writeUInt16BE(8, 7)
assert.deepEqual(decodeCdb(buffer), { kind: 'read', lba: 0x01020304, blocks: 8 })
})
it('decodes WRITE(16) LBA and transfer length', () => {
const buffer = Buffer.alloc(16)
buffer[0] = 0x8a
buffer.writeBigUInt64BE(123456789n, 2)
buffer.writeUInt32BE(16, 10)
assert.deepEqual(decodeCdb(buffer), { kind: 'write', lba: 123456789, blocks: 16 })
})
it('decodes READ CAPACITY(16) only for the right service action', () => {
assert.equal(decodeCdb(cdb(0x9e, 0x10)).kind, 'readCapacity16')
assert.equal(decodeCdb(cdb(0x9e, 0x12)).kind, 'unsupported')
})
it('clamps an out-of-range 64-bit LBA instead of throwing', () => {
// Past 2^53 an LBA no longer fits a JS number — and is past any capacity we
// can serve, so it must come back as an unservable LBA the range check
// rejects, not as an error that would tear the connection down.
const buffer = Buffer.alloc(16)
buffer[0] = 0x88
buffer.writeBigUInt64BE(0xffffffffffffffffn, 2)
buffer.writeUInt32BE(1, 10)
assert.deepEqual(decodeCdb(buffer), { kind: 'read', lba: Number.MAX_SAFE_INTEGER, blocks: 1 })
})
it('maps unknown opcodes to unsupported', () => {
assert.deepEqual(decodeCdb(cdb(0xff)), { kind: 'unsupported', opcode: 0xff })
})
})
describe('response encoders', () => {
it('builds 36-byte standard INQUIRY data for a block device', () => {
const data = buildStandardInquiry(IDENTITY)
assert.equal(data.length, 36)
assert.equal(data[0], 0x00) // direct-access block device
assert.equal(data[4], 31) // additional length
assert.equal(data.subarray(8, 16).toString('ascii').trim(), 'VATES')
assert.equal(data.subarray(16, 32).toString('ascii').trim(), 'ISCSI LUN')
})
it('builds READ CAPACITY(10) as (maxLba, blockSize)', () => {
const data = buildReadCapacity10(2048, 512)
assert.equal(data.readUInt32BE(0), 2047)
assert.equal(data.readUInt32BE(4), 512)
})
it('clamps READ CAPACITY(10) max LBA to force RC16 on large disks', () => {
const data = buildReadCapacity10(0x1_0000_0001, 512)
assert.equal(data.readUInt32BE(0), 0xffffffff)
})
it('builds READ CAPACITY(16) with a 64-bit max LBA', () => {
const data = buildReadCapacity16(0x1_0000_0001, 512)
assert.equal(data.readBigUInt64BE(0), 0x1_0000_0000n)
assert.equal(data.readUInt32BE(8), 512)
})
it('reports a single LUN 0', () => {
const data = buildReportLuns()
assert.equal(data.readUInt32BE(0), 8) // one 8-byte LUN entry
assert.deepEqual(data.subarray(8, 16), Buffer.alloc(8))
})
it('advertises supported VPD pages and rejects unknown ones', () => {
const page00 = buildVpd(0x00, IDENTITY)
assert.ok(page00 !== undefined)
assert.deepEqual([...page00.subarray(4)], [0x00, 0x80, 0x83, 0xb0])
assert.equal(buildVpd(0x55, IDENTITY), undefined)
})
it('advertises the max transfer length in the block limits page', () => {
const page = buildVpd(0xb0, IDENTITY, 2048)
assert.ok(page !== undefined)
assert.equal(page[1], 0xb0)
assert.equal(page.readUInt16BE(2), 0x3c)
assert.equal(page.readUInt32BE(8), 2048)
})
})
describe('handleScsiCommand', () => {
it('INQUIRY returns standard data trimmed to the allocation length', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(1024 * 1024))
await handleScsiCommand(cdb(0x12, 0x00, 0x00, 0x00, 36), 1, ctx, IDENTITY)
assert.equal(ctx.reads.length, 1)
assert.equal(ctx.reads[0].data.length, 36)
assert.equal(ctx.reads[0].allocationLength, 36)
})
it('READ(10) returns the backing bytes for the requested blocks', async () => {
const lun = new MemoryBlockDevice(4096)
lun.buffer.fill(0xab, 512, 1024) // block 1
const ctx = new FakeContext(lun)
const buffer = Buffer.alloc(16)
buffer[0] = 0x28
buffer.writeUInt32BE(1, 2) // LBA 1
buffer.writeUInt16BE(1, 7) // 1 block
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
assert.equal(ctx.reads.length, 1)
assert.deepEqual(ctx.reads[0].data, Buffer.alloc(512, 0xab))
})
it('WRITE(10) begins a solicited write at the right LUN offset and length', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(4096))
const buffer = Buffer.alloc(16)
buffer[0] = 0x2a
buffer.writeUInt32BE(2, 2) // LBA 2
buffer.writeUInt16BE(3, 7) // 3 blocks
await handleScsiCommand(buffer, 7, ctx, IDENTITY)
// The data transfer + GOOD response are completed later by the connection.
assert.deepEqual(ctx.writes, [{ itt: 7, lunOffset: 2 * 512, totalLength: 3 * 512 }])
assert.equal(ctx.responses.length, 0)
})
it('WRITE(10) of zero blocks returns GOOD immediately with no transfer', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(4096))
const buffer = Buffer.alloc(16)
buffer[0] = 0x2a // LBA 0, 0 blocks
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
assert.equal(ctx.writes.length, 0)
assert.deepEqual(ctx.responses, [{ status: ScsiStatus.GOOD, options: undefined }])
})
it('rejects an out-of-range READ with CHECK CONDITION', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(4096)) // 8 blocks
const buffer = Buffer.alloc(16)
buffer[0] = 0x28
buffer.writeUInt32BE(7, 2) // LBA 7
buffer.writeUInt16BE(4, 7) // 4 blocks -> past end
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
assert.equal(ctx.responses.length, 1)
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
assert.equal(ctx.responses[0].options?.sense?.[2], SenseKey.ILLEGAL_REQUEST)
})
it('reports a failed lun.read() as CHECK CONDITION / MEDIUM ERROR, not an uncaught throw', async () => {
const lun: BlockDevice = {
getSize: () => 4096,
getBlockSize: () => 512,
read: async () => {
throw new Error('backend read failed')
},
write: async () => {},
flush: async () => {},
close: async () => {},
}
const ctx = new FakeContext(lun)
const buffer = Buffer.alloc(16)
buffer[0] = 0x28
buffer.writeUInt32BE(1, 2) // LBA 1
buffer.writeUInt16BE(1, 7) // 1 block
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
assert.equal(ctx.responses.length, 1)
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
assert.equal(ctx.responses[0].options?.sense?.[2], SenseKey.MEDIUM_ERROR)
assert.equal(ctx.reads.length, 0) // sendReadData was never reached
})
it('returns CHECK CONDITION for an unsupported opcode', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(4096))
await handleScsiCommand(cdb(0xff), 1, ctx, IDENTITY)
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
assert.equal(ctx.responses[0].options?.sense?.[12], 0x20) // INVALID COMMAND OPERATION CODE
})
for (const [name, opcode] of [
['READ(10)', 0x28],
['WRITE(10)', 0x2a],
] as const) {
it(`refuses a ${name} longer than the max transfer length, without reading or soliciting data`, async () => {
let reads = 0
const lun = new MemoryBlockDevice(8192)
const read = lun.read.bind(lun)
lun.read = (offset, length) => {
reads++
return read(offset, length)
}
const ctx = new FakeContext(lun)
const buffer = Buffer.alloc(16)
buffer[0] = opcode
buffer.writeUInt16BE(3, 7) // 3 blocks, the limit is 2
await handleScsiCommand(buffer, 1, ctx, IDENTITY, 1024)
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
assert.equal(ctx.responses[0].options?.sense?.[2], SenseKey.ILLEGAL_REQUEST)
assert.equal(ctx.responses[0].options?.sense?.[12], 0x24) // INVALID FIELD IN CDB
assert.equal(reads, 0)
assert.equal(ctx.writes.length, 0)
})
}
describe('read-only LUN', () => {
class ReadOnlyBlockDevice extends MemoryBlockDevice {
readonly readOnly = true
}
it('refuses WRITE(10) with DATA PROTECT, without soliciting its data', async () => {
const ctx = new FakeContext(new ReadOnlyBlockDevice(4096))
const buffer = Buffer.alloc(16)
buffer[0] = 0x2a
buffer.writeUInt16BE(1, 7)
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
assert.equal(ctx.writes.length, 0)
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
assert.equal(ctx.responses[0].options?.sense?.[2], SenseKey.DATA_PROTECT)
assert.equal(ctx.responses[0].options?.sense?.[12], 0x27) // WRITE PROTECTED
})
it('reports write protection in MODE SENSE(6) and MODE SENSE(10)', async () => {
const ctx = new FakeContext(new ReadOnlyBlockDevice(4096))
await handleScsiCommand(cdb(0x1a, 0x00, 0x3f, 0x00, 255), 1, ctx, IDENTITY)
await handleScsiCommand(cdb(0x5a, 0x00, 0x3f, 0, 0, 0, 0, 0x01, 0x00), 2, ctx, IDENTITY)
assert.equal(ctx.reads[0].data[2] & 0x80, 0x80)
assert.equal(ctx.reads[1].data[3] & 0x80, 0x80)
})
it('does not report a writable LUN as write-protected', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(4096))
await handleScsiCommand(cdb(0x1a, 0x00, 0x3f, 0x00, 255), 1, ctx, IDENTITY)
assert.equal(ctx.reads[0].data[2] & 0x80, 0)
})
})
})

View File

@@ -0,0 +1,33 @@
/**
* A minimal counting semaphore: bounds how many `run()`-wrapped tasks execute
* at once, queueing the rest in FIFO order until a slot frees up.
*/
export class Semaphore {
#available: number
readonly #waiters: Array<() => void> = []
constructor(concurrency: number) {
if (!Number.isInteger(concurrency) || concurrency <= 0) {
throw new Error(`concurrency must be a positive integer, got ${concurrency}`)
}
this.#available = concurrency
}
async run<T>(fn: () => Promise<T>): Promise<T> {
if (this.#available > 0) {
this.#available--
} else {
await new Promise<void>(resolve => this.#waiters.push(resolve))
}
try {
return await fn()
} finally {
const next = this.#waiters.shift()
if (next !== undefined) {
next()
} else {
this.#available++
}
}
}
}

View File

@@ -0,0 +1,70 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { Semaphore } from './semaphore.mjs'
describe('Semaphore', () => {
it('rejects a non-positive-integer concurrency', () => {
assert.throws(() => new Semaphore(0), /positive integer/)
assert.throws(() => new Semaphore(-1), /positive integer/)
assert.throws(() => new Semaphore(1.5), /positive integer/)
})
it('runs tasks immediately while under the limit', async () => {
const semaphore = new Semaphore(2)
const started: Array<number> = []
await Promise.all(
[0, 1].map(i =>
semaphore.run(async () => {
started.push(i)
})
)
)
assert.deepEqual(started.sort(), [0, 1])
})
it('caps concurrency and admits the next task as soon as a slot frees', async () => {
const semaphore = new Semaphore(2)
let inFlight = 0
let maxInFlight = 0
const releases: Array<() => void> = []
const run = () =>
semaphore.run(async () => {
inFlight++
maxInFlight = Math.max(maxInFlight, inFlight)
await new Promise<void>(resolve => releases.push(resolve))
inFlight--
})
const tasks = [run(), run(), run(), run()]
// let the first batch (2, the concurrency limit) start
await new Promise(resolve => setImmediate(resolve))
assert.equal(inFlight, 2)
assert.equal(releases.length, 2)
while (releases.length > 0) {
releases.shift()!()
await new Promise(resolve => setImmediate(resolve))
}
await Promise.all(tasks)
assert.equal(maxInFlight, 2)
})
it('propagates a task failure without leaking its slot', async () => {
const semaphore = new Semaphore(1)
await assert.rejects(
semaphore.run(async () => {
throw new Error('boom')
}),
/boom/
)
// the slot must have been released despite the failure
let ran = false
await semaphore.run(async () => {
ran = true
})
assert.equal(ran, true)
})
})

View File

@@ -0,0 +1,24 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { addSerial, incrementSerial, SERIAL_MODULO } from './connection.mjs'
describe('iSCSI serial-number arithmetic', () => {
it('increments within range', () => {
assert.equal(incrementSerial(0), 1)
assert.equal(incrementSerial(41), 42)
})
it('wraps at 2^32', () => {
assert.equal(SERIAL_MODULO, 0x1_0000_0000)
assert.equal(incrementSerial(0xffffffff), 0)
assert.equal(addSerial(0xfffffffe, 5), 3)
assert.equal(addSerial(0xffffffff, 1), 0)
})
it('advertises a command window above the next expected CmdSN, wrapping cleanly', () => {
const expCmdSN = 0xfffffff0
const window = 64
assert.equal(addSerial(expCmdSN, window), (0xfffffff0 + 64) % SERIAL_MODULO)
})
})

View File

@@ -0,0 +1,244 @@
import assert from 'node:assert/strict'
import { execFile, execFileSync } from 'node:child_process'
import { mkdtemp, open as openFile, readFile, rm, truncate, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { promisify } from 'node:util'
import { after, before, describe, it } from 'node:test'
import { FileBlockDevice, IscsiTarget } from './index.mjs'
const execFileP = promisify(execFile)
const IQN = 'iqn.2024-01.tech.vates:integ'
const LUN_SIZE = 8 * 1024 * 1024 // 8 MiB
const BLOCK_SIZE = 512
/**
* Determine why the open-iscsi interop test cannot run here (or `false` if it
* can). It needs root (to manage iSCSI sessions and flush block buffers) and the
* open-iscsi initiator — the exact software XCP-ng uses. As a lighter, root-free
* local alternative, the `libiscsi` userspace tools (`iscsi-inq`, `iscsi-ls`)
* can smoke-test discovery/INQUIRY without touching the kernel initiator.
*/
function skipReason(): string | false {
if (process.getuid === undefined || process.getuid() !== 0) {
return 'requires root to manage iSCSI sessions'
}
try {
execFileSync('iscsiadm', ['--version'], { stdio: 'ignore' })
} catch {
return 'requires open-iscsi (iscsiadm not found)'
}
return false
}
async function iscsiadm(args: string[]): Promise<string> {
const { stdout } = await execFileP('iscsiadm', args)
return stdout
}
/** Parse `iscsiadm -m session -P 3` for the attached SCSI disk's name (e.g. `sdb`) of our target. */
function parseAttachedDisk(sessionOutput: string): string | undefined {
const lines = sessionOutput.split('\n')
const targetLine = lines.findIndex(line => line.includes(IQN))
if (targetLine === -1) {
return undefined
}
for (let i = targetLine; i < lines.length; i++) {
const match = lines[i].match(/Attached scsi disk (sd\w+)/)
if (match !== null) {
return match[1]
}
}
return undefined
}
/**
* Neither the device node nor a nonzero `/sys/class/block/<name>/size` means
* the disk can be opened: the sd driver sets the size before
* `device_add_disk()`, which creates the sysfs entry, then the `/dev` node, then
* registers the block device: until then `open()` fails with ENOENT or ENXIO.
* Only an `open()` that succeeds means ready.
*/
async function isDeviceReady(name: string): Promise<boolean> {
try {
await (await openFile(`/dev/${name}`, 'r')).close()
return true
} catch (error) {
if (error instanceof Error && 'code' in error && (error.code === 'ENXIO' || error.code === 'ENOENT')) {
return false
}
throw error
}
}
async function waitForDevice(): Promise<string> {
for (let attempt = 0; attempt < 40; attempt++) {
const session = await iscsiadm(['-m', 'session', '-P', '3']).catch(() => '')
const name = parseAttachedDisk(session)
if (name !== undefined && (await isDeviceReady(name))) {
return `/dev/${name}`
}
await delay(250)
}
throw new Error('iSCSI block device did not appear')
}
describe('open-iscsi interop', { skip: skipReason() }, () => {
let dir: string
let backingPath: string
let target: IscsiTarget
let portal: string
before(async () => {
dir = await mkdtemp(join(tmpdir(), 'vates-iscsi-integ-'))
backingPath = join(dir, 'lun.img')
await writeFile(backingPath, Buffer.alloc(0))
await truncate(backingPath, LUN_SIZE)
target = new IscsiTarget({
iqn: IQN,
host: '127.0.0.1',
port: 0,
lun: new FileBlockDevice({ path: backingPath, blockSize: BLOCK_SIZE }),
})
await target.listen()
const address = target.address()
assert.ok(address !== undefined)
portal = `127.0.0.1:${address.port}`
})
after(async () => {
// Best-effort teardown: logout + drop the node record so the host is clean.
await iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '--logout']).catch(() => {})
await iscsiadm(['-m', 'node', '-o', 'delete', '-T', IQN, '-p', portal]).catch(() => {})
await target?.close()
await rm(dir, { recursive: true, force: true })
})
it('discovers, logs in, writes and reads back a LUN', async () => {
const discovery = await iscsiadm(['-m', 'discovery', '-t', 'sendtargets', '-p', portal])
assert.match(discovery, new RegExp(IQN), 'SendTargets discovery should list our IQN')
await iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '--login'])
const device = await waitForDevice()
// Write a recognizable pattern at block 10 through the kernel initiator.
const lba = 10
const offset = lba * BLOCK_SIZE
const pattern = Buffer.alloc(BLOCK_SIZE)
for (let i = 0; i < pattern.length; i++) {
pattern[i] = (i * 3 + 5) & 0xff
}
const writeHandle = await openFile(device, 'r+')
try {
await writeHandle.write(pattern, 0, pattern.length, offset)
await writeHandle.sync()
} finally {
await writeHandle.close()
}
// The WRITE path must have landed the bytes in the backing file.
const onDisk = await readFile(backingPath)
assert.deepEqual(onDisk.subarray(offset, offset + BLOCK_SIZE), pattern, 'backing file updated by WRITE')
// Drop the page cache so the read traverses the target (READ path).
await execFileP('blockdev', ['--flushbufs', device]).catch(() => {})
const readHandle = await openFile(device, 'r')
try {
const buffer = Buffer.alloc(BLOCK_SIZE)
await readHandle.read(buffer, 0, BLOCK_SIZE, offset)
assert.deepEqual(buffer, pattern, 'data read back through the target matches')
} finally {
await readHandle.close()
}
})
})
// The target as CHAP authenticator against the real open-iscsi initiator (the
// XCP-ng interop role). Credentials go into the node record's node.session.auth
// keys, exactly as XCP-ng's `device-config:chapuser`/`chappassword` do.
describe('open-iscsi CHAP interop', { skip: skipReason() }, () => {
// open-iscsi requires the initiator (outgoing) secret to be 12-16 characters.
const CHAP = { user: 'alice', secret: 'supersecret1234' }
let dir: string
let backingPath: string
let target: IscsiTarget
let portal: string
async function configureAuth(user: string, secret: string): Promise<void> {
const set = (name: string, value: string) =>
iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '-o', 'update', '-n', name, '-v', value])
await set('node.session.auth.authmethod', 'CHAP')
await set('node.session.auth.username', user)
await set('node.session.auth.password', secret)
}
before(async () => {
dir = await mkdtemp(join(tmpdir(), 'vates-iscsi-chap-integ-'))
backingPath = join(dir, 'lun.img')
await writeFile(backingPath, Buffer.alloc(0))
await truncate(backingPath, LUN_SIZE)
target = new IscsiTarget({
iqn: IQN,
host: '127.0.0.1',
port: 0,
lun: new FileBlockDevice({ path: backingPath, blockSize: BLOCK_SIZE }),
chap: CHAP,
})
await target.listen()
const address = target.address()
assert.ok(address !== undefined)
portal = `127.0.0.1:${address.port}`
// Discovery creates the node record we then attach auth to.
await iscsiadm(['-m', 'discovery', '-t', 'sendtargets', '-p', portal])
})
after(async () => {
await iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '--logout']).catch(() => {})
await iscsiadm(['-m', 'node', '-o', 'delete', '-T', IQN, '-p', portal]).catch(() => {})
await target?.close()
await rm(dir, { recursive: true, force: true })
})
it('rejects login with a wrong secret', async () => {
await configureAuth(CHAP.user, 'wrongsecret12345')
await assert.rejects(
iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '--login']),
'open-iscsi login must fail when the CHAP secret is wrong'
)
})
it('authenticates with the right secret and does IO', async () => {
await configureAuth(CHAP.user, CHAP.secret)
await iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '--login'])
const device = await waitForDevice()
const lba = 7
const offset = lba * BLOCK_SIZE
const pattern = Buffer.alloc(BLOCK_SIZE)
for (let i = 0; i < pattern.length; i++) {
pattern[i] = (i * 5 + 9) & 0xff
}
const writeHandle = await openFile(device, 'r+')
try {
await writeHandle.write(pattern, 0, pattern.length, offset)
await writeHandle.sync()
} finally {
await writeHandle.close()
}
await execFileP('blockdev', ['--flushbufs', device]).catch(() => {})
const readHandle = await openFile(device, 'r')
try {
const buffer = Buffer.alloc(BLOCK_SIZE)
await readHandle.read(buffer, 0, BLOCK_SIZE, offset)
assert.deepEqual(buffer, pattern, 'data read back over an authenticated session matches')
} finally {
await readHandle.close()
}
})
})

149
@vates/iscsi/src/types.mts Normal file
View File

@@ -0,0 +1,149 @@
import type { BlockDevice } from './backend.mjs'
import { ScsiOpcode, SERVICE_ACTION_READ_CAPACITY_16 } from './constants.mjs'
// --- Decoded SCSI requests --------------------------------------------------
/** A SCSI command we recognize, decoded from its CDB. */
export type ScsiRequest =
| { kind: 'inquiry'; evpd: boolean; pageCode: number; allocationLength: number }
| { kind: 'reportLuns'; allocationLength: number }
| { kind: 'readCapacity10' }
| { kind: 'readCapacity16'; allocationLength: number }
| { kind: 'read'; lba: number; blocks: number }
| { kind: 'write'; lba: number; blocks: number }
| { kind: 'testUnitReady' }
| { kind: 'requestSense'; allocationLength: number }
| { kind: 'modeSense'; long: boolean; pageCode: number; allocationLength: number }
| { kind: 'syncCache' }
| { kind: 'unsupported'; opcode: number }
/** Largest LBA representable as a safe JS integer; guards 64-bit CDB decoding. */
const MAX_SAFE_LBA = Number.MAX_SAFE_INTEGER
/**
* Read a 64-bit LBA, clamped to what a JS number holds exactly.
*
* An LBA above that is past the capacity of any device we can serve, so the
* clamp cannot collide with an addressable block: the caller's
* `lba + blocks > blockCount` check rejects it with LBA OUT OF RANGE, which is
* the answer SCSI wants anyway. Throwing here instead would take the whole
* connection down (the error unwinds to `serve()`, which destroys the socket)
* over a single bogus CDB.
*/
function readLba64(cdb: Buffer, offset: number): number {
const lba = cdb.readBigUInt64BE(offset)
return lba > BigInt(MAX_SAFE_LBA) ? MAX_SAFE_LBA : Number(lba)
}
/**
* Decode a SCSI CDB into a {@link ScsiRequest}. Unknown opcodes (and READ
* CAPACITY(16)'s sibling service actions we do not implement) map to
* `{ kind: 'unsupported' }` so the caller can return CHECK CONDITION. Never
* throws: every 16-byte CDB decodes to something the caller can answer.
*/
export function decodeCdb(cdb: Buffer): ScsiRequest {
const opcode = cdb[0]
switch (opcode) {
case ScsiOpcode.INQUIRY:
return {
kind: 'inquiry',
evpd: (cdb[1] & 0x01) !== 0,
pageCode: cdb[2],
allocationLength: cdb.readUInt16BE(3),
}
case ScsiOpcode.REPORT_LUNS:
return { kind: 'reportLuns', allocationLength: cdb.readUInt32BE(6) }
case ScsiOpcode.READ_CAPACITY_10:
return { kind: 'readCapacity10' }
case ScsiOpcode.SERVICE_ACTION_IN_16:
if ((cdb[1] & 0x1f) === SERVICE_ACTION_READ_CAPACITY_16) {
return { kind: 'readCapacity16', allocationLength: cdb.readUInt32BE(10) }
}
return { kind: 'unsupported', opcode }
case ScsiOpcode.READ_10:
return { kind: 'read', lba: cdb.readUInt32BE(2), blocks: cdb.readUInt16BE(7) }
case ScsiOpcode.WRITE_10:
return { kind: 'write', lba: cdb.readUInt32BE(2), blocks: cdb.readUInt16BE(7) }
case ScsiOpcode.READ_16:
return { kind: 'read', lba: readLba64(cdb, 2), blocks: cdb.readUInt32BE(10) }
case ScsiOpcode.WRITE_16:
return { kind: 'write', lba: readLba64(cdb, 2), blocks: cdb.readUInt32BE(10) }
case ScsiOpcode.TEST_UNIT_READY:
return { kind: 'testUnitReady' }
case ScsiOpcode.REQUEST_SENSE:
return { kind: 'requestSense', allocationLength: cdb[4] }
case ScsiOpcode.MODE_SENSE_6:
return { kind: 'modeSense', long: false, pageCode: cdb[2] & 0x3f, allocationLength: cdb[4] }
case ScsiOpcode.MODE_SENSE_10:
return { kind: 'modeSense', long: true, pageCode: cdb[2] & 0x3f, allocationLength: cdb.readUInt16BE(7) }
case ScsiOpcode.SYNCHRONIZE_CACHE_10:
case ScsiOpcode.SYNCHRONIZE_CACHE_16:
return { kind: 'syncCache' }
default:
return { kind: 'unsupported', opcode }
}
}
// --- Session / connection state shared across modules -----------------------
/** Operational parameters fixed during login that the SCSI layer depends on. */
export interface NegotiatedParams {
/** Initiator's MaxRecvDataSegmentLength — the cap on each Data-In PDU we send. */
initiatorMaxRecvDataSegmentLength: number
/** Max bytes solicited per R2T. */
maxBurstLength: number
}
export type SessionType = 'Discovery' | 'Normal'
/**
* A single one-way CHAP credential. On the target it is the incoming credential
* an initiator must present; on the initiator it is the credential presented to
* the target when challenged. Mutual (two-way) CHAP is not yet supported.
*/
export interface ChapCredentials {
/** CHAP username (`CHAP_N`). */
readonly user: string
/** CHAP shared secret. */
readonly secret: string
}
/** Sense data to attach to a CHECK CONDITION response. */
export interface SenseInfo {
key: number
asc: number
ascq: number
}
export interface ScsiResponseOptions {
/** Fixed-format sense data buffer (already serialized). */
sense?: Buffer
/** Residual byte counts for under/overflow reporting. */
residualOverflow?: number
residualUnderflow?: number
}
/**
* The capabilities the SCSI command handler needs from its connection: the LUN
* backend plus the three ways a command can complete (stream read data, begin a
* solicited write, or just return status). The connection implements this;
* keeping it an interface avoids a dependency cycle between `scsi` and
* `connection`.
*/
export interface CommandContext {
readonly lun: BlockDevice
/**
* Send `data` as Data-In PDU(s) with GOOD status piggybacked on the last,
* trimmed/residual-reported against `allocationLength`.
*/
sendReadData(itt: number, data: Buffer, allocationLength: number): Promise<void>
/**
* Begin a solicited write of `totalLength` bytes landing at `lunOffset`: the
* connection registers the transfer and sends the first R2T, then returns. The
* write executes and its SCSI Response is sent once the Data-Out PDUs arrive
* (which may be interleaved with other commands).
*/
beginWrite(itt: number, lunOffset: number, totalLength: number): Promise<void>
/** Send a standalone SCSI Response carrying `status` (+ optional sense/residual). */
sendScsiResponse(itt: number, status: number, options?: ScsiResponseOptions): Promise<void>
}

View File

@@ -0,0 +1,151 @@
import { FLAG_FINAL, TargetOpcode } from './constants.mjs'
import { allocBhs, assemblePdu } from './pdu.mjs'
/** Sequence numbers stamped into an outbound R2T (which does not advance StatSN). */
export interface SequenceSnapshot {
statSN: number
expCmdSN: number
maxCmdSN: number
}
/**
* Build a Ready To Transfer (R2T) PDU soliciting `desiredLength` bytes of write
* data starting at `bufferOffset`.
*/
export function buildR2t(
params: {
itt: number
r2tSN: number
targetTransferTag: number
bufferOffset: number
desiredLength: number
},
sequence: SequenceSnapshot
): Buffer {
const bhs = allocBhs(TargetOpcode.R2T)
bhs[1] = FLAG_FINAL // R2T always has the Final bit set
bhs.writeUInt32BE(params.itt, 16)
bhs.writeUInt32BE(params.targetTransferTag, 20)
bhs.writeUInt32BE(sequence.statSN, 24)
bhs.writeUInt32BE(sequence.expCmdSN, 28)
bhs.writeUInt32BE(sequence.maxCmdSN, 32)
bhs.writeUInt32BE(params.r2tSN, 36)
bhs.writeUInt32BE(params.bufferOffset, 40)
bhs.writeUInt32BE(params.desiredLength, 44)
return assemblePdu(bhs)
}
/**
* State of one in-progress WRITE: data is solicited one burst at a time (we pin
* MaxOutstandingR2T=1) and accumulated as Data-Out PDUs arrive, interleaved with
* any other commands on the connection.
*/
export interface PendingWrite {
/** Initiator Task Tag of the WRITE command; Data-Out PDUs are routed by it. */
readonly itt: number
/** Target Transfer Tag echoed in this command's R2Ts / Data-Out PDUs. */
readonly targetTransferTag: number
/** Byte offset within the LUN where the data must land. */
readonly lunOffset: number
/** Assembled write payload. */
readonly buffer: Buffer
/** Total bytes expected. */
readonly totalLength: number
/**
* Bytes of {@link buffer} filled, counted from offset 0 and contiguous by
* construction — see {@link receiveDataOut}. Never a plain count of bytes
* received off the wire, which is not the same thing.
*/
contiguousReceived: number
/** Bytes solicited so far (one burst ahead of `contiguousReceived`). */
solicited: number
/** Next R2TSN to emit. */
r2tSN: number
}
/** One inbound Data-Out PDU, reduced to what the write path needs from it. */
export interface DataOutSegment {
/** Target Transfer Tag echoed by the initiator (BHS bytes 20-23). */
readonly targetTransferTag: number
/** Where these bytes belong, relative to the start of the command's data (BHS bytes 40-43). */
readonly bufferOffset: number
readonly data: Buffer
}
/** The PDU was placed in the staging buffer. */
export interface PlacedDataOut {
readonly ok: true
/** Every byte of the command has landed: the write can be committed to the LUN. */
readonly complete: boolean
/** The solicited burst is fully received: the next one can be solicited. */
readonly burstComplete: boolean
}
/** The PDU cannot be placed; the command must be aborted rather than committed. */
export interface RefusedDataOut {
readonly ok: false
/** Why, for the log line accompanying the Reject. */
readonly reason: string
}
export type DataOutOutcome = PlacedDataOut | RefusedDataOut
/**
* Validate one Data-Out PDU against its pending write and, only if it belongs
* exactly where the buffer is still unfilled, copy it in. Validation and copy
* live together on purpose: the checks are what make the copy in-bounds, so a
* caller must not be able to do one without the other.
*
* A Data-Out must start exactly where the previous one ended. That strictness
* is what makes `contiguousReceived` mean "bytes of the buffer filled" — and
* only that meaning makes the completion test sound. Counting bytes as they
* arrive off the wire does not work, because `Buffer.copy` silently clamps:
*
* - 512 bytes at BufferOffset 512 of a 512-byte write copies *nothing*,
* - 512 bytes at BufferOffset 256 of a 512-byte write copies only 256,
* - the same 256 bytes sent twice copies 256 bytes over themselves.
*
* Each of those reaches `totalLength` bytes received with part of the buffer
* never written, so the target would commit a partly-applied write and answer
* GOOD — an acknowledged write silently losing data, which is worse than
* failing it. Before this was a contiguity check the staging buffer was also
* `allocUnsafe`, so the unwritten part leaked recycled heap to the initiator.
*
* Requiring contiguity is legitimate only because of what login declares in
* `#negotiateOperational`: `InitialR2T=Yes` (no unsolicited data, so every
* Data-Out is covered by an R2T and its TTT must match), `DataPDUInOrder=Yes`
* (RFC 7143: within a sequence, Data-Out PDUs MUST be in increasing
* BufferOffset order) and `MaxOutstandingR2T=1` (only one burst is ever
* solicited, so no other range can legitimately be in flight). Each of those
* is the winning side of its negotiation rule, so an initiator cannot talk us
* out of them. Should any of the three ever change, this must become per-burst
* range accounting instead of a single high-water mark.
*/
export function receiveDataOut(pending: PendingWrite, segment: DataOutSegment): DataOutOutcome {
const { targetTransferTag, bufferOffset, data } = segment
if (targetTransferTag !== pending.targetTransferTag) {
return {
ok: false,
reason: `Target Transfer Tag ${targetTransferTag} is not this command's ${pending.targetTransferTag}`,
}
}
if (bufferOffset !== pending.contiguousReceived) {
return { ok: false, reason: `BufferOffset ${bufferOffset} is not the expected ${pending.contiguousReceived}` }
}
// `solicited` only ever grows to at most `totalLength`, so bounding by it also
// keeps the copy inside the buffer — no clamping, no silent short copy.
if (bufferOffset + data.length > pending.solicited) {
return {
ok: false,
reason: `${data.length} bytes at ${bufferOffset} exceed the ${pending.solicited} bytes solicited`,
}
}
data.copy(pending.buffer, bufferOffset)
pending.contiguousReceived += data.length
return {
ok: true,
complete: pending.contiguousReceived === pending.totalLength,
burstComplete: pending.contiguousReceived === pending.solicited,
}
}

View File

@@ -0,0 +1,168 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { FLAG_FINAL, TargetOpcode } from './constants.mjs'
import { buildR2t, receiveDataOut, type PendingWrite } from './writePath.mjs'
const TTT = 7
const BLOCK = 512
/**
* A pending WRITE with its first burst already solicited, as `beginWrite`
* leaves it. `solicited` defaults to the whole command (one burst), which is
* what a write below MaxBurstLength gets.
*/
const pending = ({
totalLength = BLOCK,
solicited = totalLength,
}: { totalLength?: number; solicited?: number } = {}): PendingWrite => ({
itt: 1,
targetTransferTag: TTT,
lunOffset: 0,
buffer: Buffer.alloc(totalLength),
totalLength,
contiguousReceived: 0,
solicited,
r2tSN: 1,
})
const dataOut = (bufferOffset: number, length: number, fill = 0xab) => ({
targetTransferTag: TTT,
bufferOffset,
data: Buffer.alloc(length, fill),
})
describe('buildR2t', () => {
it('solicits the requested range, always with the final bit set', () => {
const r2t = buildR2t(
{ itt: 42, r2tSN: 3, targetTransferTag: TTT, bufferOffset: 1024, desiredLength: 512 },
{ statSN: 9, expCmdSN: 10, maxCmdSN: 74 }
)
assert.equal(r2t[0], TargetOpcode.R2T)
assert.equal(r2t[1], FLAG_FINAL)
assert.equal(r2t.readUInt32BE(16), 42)
assert.equal(r2t.readUInt32BE(20), TTT)
assert.equal(r2t.readUInt32BE(36), 3)
assert.equal(r2t.readUInt32BE(40), 1024)
assert.equal(r2t.readUInt32BE(44), 512)
})
})
describe('receiveDataOut', () => {
it('places a burst delivered in one PDU and reports it complete', () => {
const write = pending()
const outcome = receiveDataOut(write, dataOut(0, BLOCK))
assert.deepEqual(outcome, { ok: true, complete: true, burstComplete: true })
assert.equal(write.contiguousReceived, BLOCK)
assert.deepEqual(write.buffer, Buffer.alloc(BLOCK, 0xab))
})
it('places consecutive PDUs of one burst, completing only on the last', () => {
const write = pending({ totalLength: 3 * BLOCK })
const first = receiveDataOut(write, dataOut(0, BLOCK, 0x11))
const second = receiveDataOut(write, dataOut(BLOCK, BLOCK, 0x22))
const third = receiveDataOut(write, dataOut(2 * BLOCK, BLOCK, 0x33))
assert.deepEqual(first, { ok: true, complete: false, burstComplete: false })
assert.deepEqual(second, { ok: true, complete: false, burstComplete: false })
assert.deepEqual(third, { ok: true, complete: true, burstComplete: true })
assert.deepEqual(
write.buffer,
Buffer.concat([Buffer.alloc(BLOCK, 0x11), Buffer.alloc(BLOCK, 0x22), Buffer.alloc(BLOCK, 0x33)])
)
})
it('reports the burst complete, but not the command, when more remains to solicit', () => {
const write = pending({ totalLength: 2 * BLOCK, solicited: BLOCK })
const outcome = receiveDataOut(write, dataOut(0, BLOCK))
assert.deepEqual(outcome, { ok: true, complete: false, burstComplete: true })
})
// The three ways a plain count of received bytes reached `totalLength` with
// part of the staging buffer never written — each one an acknowledged write
// that silently lost data, since `Buffer.copy` clamps instead of throwing.
describe('refuses a PDU that would leave a hole in the buffer', () => {
it('past the end of the buffer, where the copy would land nothing', () => {
const write = pending()
const outcome = receiveDataOut(write, dataOut(BLOCK, BLOCK))
assert.equal(outcome.ok, false)
assert.equal(write.contiguousReceived, 0)
assert.deepEqual(write.buffer, Buffer.alloc(BLOCK))
})
it('straddling the end, where the copy would be silently short', () => {
const write = pending()
const outcome = receiveDataOut(write, dataOut(BLOCK / 2, BLOCK))
assert.equal(outcome.ok, false)
assert.equal(write.contiguousReceived, 0)
})
it('repeating a range already received, which would never fill the rest', () => {
const write = pending()
assert.equal(receiveDataOut(write, dataOut(0, BLOCK / 2, 0x11)).ok, true)
const outcome = receiveDataOut(write, dataOut(0, BLOCK / 2, 0x22))
assert.equal(outcome.ok, false)
// the first PDU's bytes are still there, and the rest is still unfilled
assert.equal(write.contiguousReceived, BLOCK / 2)
assert.deepEqual(write.buffer, Buffer.concat([Buffer.alloc(BLOCK / 2, 0x11), Buffer.alloc(BLOCK / 2)]))
})
it('skipping ahead, leaving a gap before it', () => {
const write = pending({ totalLength: 2 * BLOCK })
const outcome = receiveDataOut(write, dataOut(BLOCK, BLOCK))
assert.equal(outcome.ok, false)
assert.equal(write.contiguousReceived, 0)
})
})
it('refuses bytes beyond the current burst, even when the buffer could hold them', () => {
// 2 blocks solicited of a 4-block write: the last 2 are only legitimate
// after the next R2T, so accepting them now would break burst accounting
const write = pending({ totalLength: 4 * BLOCK, solicited: 2 * BLOCK })
const outcome = receiveDataOut(write, dataOut(0, 3 * BLOCK))
assert.equal(outcome.ok, false)
assert.equal(write.contiguousReceived, 0)
})
it("refuses a PDU carrying another command's Target Transfer Tag", () => {
const write = pending()
const outcome = receiveDataOut(write, { ...dataOut(0, BLOCK), targetTransferTag: TTT + 1 })
assert.equal(outcome.ok, false)
assert.equal(write.contiguousReceived, 0)
})
it('explains why it refused, for the log line next to the Reject', () => {
const outcome = receiveDataOut(pending(), dataOut(BLOCK, BLOCK))
assert.equal(outcome.ok, false)
assert.match(outcome.ok === false ? outcome.reason : '', /BufferOffset/)
})
it('accepts a zero-length PDU without disturbing the coverage it has', () => {
const write = pending()
receiveDataOut(write, dataOut(0, BLOCK / 2, 0x11))
const outcome = receiveDataOut(write, dataOut(BLOCK / 2, 0))
assert.deepEqual(outcome, { ok: true, complete: false, burstComplete: false })
assert.equal(write.contiguousReceived, BLOCK / 2)
})
})

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2022",
"module": "ESNext",
"types": ["node"], // Only use types from Node.js and packages that are explicitly imported
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true, // Skip type-checking dependency .d.ts (e.g. @xen-orchestra/disk-transform), as it does itself
"declaration": true, // Generates `.d.ts` files for type safety
"outDir": "dist" // Outputs compiled code to the `dist` directory
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,6 +1,12 @@
### `new NdbClient({address, exportname, secure = true, port = 10809})`
This package provides a NBD client, in two flavors sharing the same protocol
implementation (`AbstractNbdClient`): over the network, or over the standard
streams of a NBD server run as a child process.
create a new nbd client
### `new NbdClient({address, exportname, cert, port = 10809})`
Create a new nbd client connecting to a server over TCP.
`NbdClient` is also exported as `NbdTcpClient`.
```js
import NbdClient from '@vates/nbd-client'
@@ -14,3 +20,55 @@ await client.connect()
const block = await client.readBlock(blockIndex, BlockSize)
await client.disconnect()
```
### `new NbdStdioClient({command, args, exportname})`
Create a new nbd client talking to a server through its standard streams, like
`nbdkit --single` or any server reading its queries on stdin and writing its
answers on stdout.
The process is spawned by `connect()` and stopped by `disconnect()`.
```js
import { NbdStdioClient } from '@vates/nbd-client'
const client = new NbdStdioClient({
command: 'nbdkit',
args: ['--single', '--exit-with-parent', '--read-only', 'file', '/path/to/disk.raw'],
exportname: '', // optional, the default export of the server
})
await client.connect()
const block = await client.readBlock(blockIndex, BlockSize)
await client.disconnect()
```
There is no TLS here: the transport is a pair of pipes, there is nothing to
encrypt.
`readBlock()` retries on error, and a retry reconnects: with this client that
means killing the server and spawning a new one, so the command must be
restartable and must serve the same content on each run.
`getMap()` is not implemented, since `nbdinfo` needs either an URI or a server
supporting systemd socket activation: compute the map on the caller side and
pass it explicitly, for example `new NbdDisk(infos, blockSize, { dataMap })`.
The tail of the server stderr is available as `client.stderr` and attached to
the errors raised when the server dies unexpectedly.
### Writing another client
Subclass `AbstractNbdClient` and implement the transport:
- `_openTransport()`: **required**, returns `{ readable, writable }`, called on
every (re)connection.
- `_secureTransport(transport)`: optional, called during the handshake, the only
place where the transport can be upgraded (`NBD_OPT_STARTTLS`).
- `_closeTransport(transport, lastMessage)`: optional, must hand over
`lastMessage` (`NBD_CMD_DISC`) before closing.
- `_destroyTransport(transport)`: optional, forceful cleanup, must not throw.
- `getMap(signal)`: optional, the base implementation rejects with the code
`NBD_MAP_UNSUPPORTED`.
Any extra property set on the transport object is passed back to
`_closeTransport()` and `_destroyTransport()`.

View File

@@ -0,0 +1,586 @@
import assert from 'node:assert'
import { pRetry, pDelay, pTimeout, pFromCallback } from 'promise-toolbox'
import { readChunkStrict } from '@vates/read-chunk'
import { createLogger } from '@xen-orchestra/log'
import {
INIT_PASSWD,
NBD_CMD_DISC,
NBD_CMD_READ,
NBD_DEFAULT_BLOCK_SIZE,
NBD_FLAG_FIXED_NEWSTYLE,
NBD_FLAG_HAS_FLAGS,
NBD_OPT_EXPORT_NAME,
NBD_OPT_REPLY_MAGIC,
NBD_REPLY_ACK,
NBD_REPLY_MAGIC,
NBD_REQUEST_MAGIC,
OPTS_MAGIC,
} from './constants.mjs'
const { debug, warn } = createLogger('vates:nbd-client')
// documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
/**
* A pair of streams carrying the NBD protocol.
*
* For a TCP client both are the same socket, for a client talking to a server
* through its standard streams they are the two ends of two different pipes.
*
* @typedef {object} NbdTransport
* @property {import('node:stream').Readable} readable - where the server answers are read from
* @property {import('node:stream').Writable} writable - where the client queries are written to
*/
/**
* Transport agnostic NBD client: it implements the handshake and the
* transmission phases, and delegates the opening/closing of the underlying
* streams to its subclasses.
*
* Subclasses MUST implement `_openTransport()` and MAY override
* `_secureTransport()`, `_closeTransport()`, `_destroyTransport()` and `getMap()`.
*
* @abstract
*/
export default class AbstractNbdClient {
#exportName
#exportSize
/** @type {NbdTransport|undefined} */
#transport
// incremented on each connection attempt: an attempt which is not the last
// one anymore has been superseded (its caller timed out on it) and must not
// publish itself
#connectGeneration = 0
// generation of the attempt which published the current transport, undefined
// when not connected: an error on a transport of another generation is stale
// and must be ignored
#connectedGeneration
#waitBeforeReconnect
#readBlockRetries
#reconnectRetry
#connectTimeout
#messageTimeout
// AFAIK, there is no guaranty the server answers in the same order as the queries
// so we handle a backlog of command waiting for response and handle concurrency manually
#waitingForResponse // there is already a listener waiting for a response
#nextCommandQueryId = BigInt(0)
// map of command waiting for a response queryId => { size/*in byte*/, resolve, reject}
#commandQueryBacklog = new Map()
#reconnectingPromise
/**
* @param {object} settings
* @param {string} [settings.exportname] - the NBD export to open, empty for the default one
* @param {object} [options]
* @param {number} [options.connectTimeout]
* @param {number} [options.messageTimeout]
* @param {number} [options.waitBeforeReconnect]
* @param {number} [options.readBlockRetries]
* @param {number} [options.reconnectRetry]
*/
constructor(
{ exportname = '' },
{
connectTimeout = 6e4,
messageTimeout = 6e4,
waitBeforeReconnect = 1e3,
readBlockRetries = 5,
reconnectRetry = 5,
} = {}
) {
this.#exportName = exportname
this.#waitBeforeReconnect = waitBeforeReconnect
this.#readBlockRetries = readBlockRetries
this.#reconnectRetry = reconnectRetry
this.#connectTimeout = connectTimeout
this.#messageTimeout = messageTimeout
}
get exportSize() {
return this.#exportSize
}
get exportName() {
return this.#exportName
}
get connected() {
return this.#connectedGeneration !== undefined
}
/* ---------------------------------------------------------------------------
* transport, to be implemented by the subclasses
* ------------------------------------------------------------------------- */
/**
* Open the streams used to talk to the NBD server.
*
* Called on every (re)connection: it must be able to open a brand new
* transport each time it is called.
*
* @abstract
* @returns {Promise<NbdTransport>}
*/
async _openTransport() {
throw new Error(`${this.constructor.name} must implement _openTransport()`)
}
/**
* Hook called during the handshake, after the client flags have been sent and
* before the export is selected: this is the only moment where the transport
* can be upgraded (NBD_OPT_STARTTLS).
*
* Returning another transport than the one received replaces it, returning
* the same one is a no-op.
*
* @param {NbdTransport} transport
* @returns {Promise<NbdTransport>}
*/
async _secureTransport(transport) {
return transport
}
/**
* Gracefully close the transport.
*
* `lastMessage` is a ready to send NBD_CMD_DISC: implementations must hand it
* over to the server (it is the only clean way to tell it we're done) before
* closing their side.
*
* Failing or timing out here is not fatal: `_destroyTransport()` will be
* called as a fallback.
*
* @param {NbdTransport} transport
* @param {Buffer} lastMessage
* @returns {Promise<void>}
*/
async _closeTransport(transport, lastMessage) {
await pFromCallback(cb => transport.writable.end(lastMessage, cb))
}
/**
* Forcefully release the transport and everything backing it.
*
* Must be idempotent, and must not throw.
*
* @param {NbdTransport} transport
* @returns {void}
*/
_destroyTransport(transport) {
transport.readable.destroy()
if (transport.writable !== transport.readable) {
transport.writable.destroy()
}
}
/**
* Returns the map of the file with holes, zeros and data, useful to handle
* efficiently sparse sources.
*
* The base implementation is unable to compute it: subclasses able to do so
* must override this method.
*
* To implement this here: use structured replies if the server supports them,
* and then ask for BLOCK_STATUS.
*
* @returns {Promise<{ offset: number, length: number, type: number }[]>}
* A promise that resolves to an array where each object represents a segment:
* - `offset` — The byte offset from the start.
* - `length` — The size of the segment in bytes.
* - `type` — A numeric code indicating the segment type ( 0 means data).
*/
async getMap() {
const error = new Error(`${this.constructor.name} does not support getMap()`)
error.code = 'NBD_MAP_UNSUPPORTED'
throw error
}
/* ---------------------------------------------------------------------------
* connection
* ------------------------------------------------------------------------- */
#onTransportError(generation, error) {
// without this listener an error on the transport would be thrown as an
// uncaught exception, and pending reads would only fail on message timeout
//
// an error on a transport which is not the current one is expected (a
// closed transport can be reset late by the other end, after a reconnection)
// and must not reject the reads of the current transport
//
// an error during the handshake is reported by the handshake reads
// themselves, through the connect() rejection
if (generation !== this.#connectedGeneration) {
debug('error on a stale nbd transport', { error })
return
}
warn('error on the nbd transport', { error })
this.#rejectAll(error)
}
// the generation is used instead of the transport itself since an attempt can
// own multiple transports (the raw one and the one upgraded to TLS)
#watchTransport(transport, generation) {
const onError = error => this.#onTransportError(generation, error)
transport.readable.on('error', onError)
if (transport.writable !== transport.readable) {
transport.writable.on('error', onError)
}
}
async #connect() {
const generation = ++this.#connectGeneration
// the transport is kept local until the client is connected: connect() does
// not cancel on timeout, so a superseded attempt must not be able to use,
// replace or destroy the transport of the attempt which replaced it
let transport = await this._openTransport()
this.#watchTransport(transport, generation)
try {
// the transport can be replaced during the handshake (TLS upgrade)
transport = await this.#handshake(transport, generation)
if (generation !== this.#connectGeneration) {
const error = new Error('this connection has been superseded by a newer one')
error.code = 'NBD_CONNECT_SUPERSEDED'
throw error
}
} catch (error) {
// don't leak the transport (a socket, a child process, ...): disconnect()
// is a no-op as long as we're not connected, so nobody else will close it
this.#destroyTransport(transport)
throw error
}
this.#transport = transport
this.#connectedGeneration = generation
// reset internal state if we reconnected a nbd client
this.#commandQueryBacklog = new Map()
this.#waitingForResponse = false
}
async connect() {
const promise = this.#connect()
try {
return await pTimeout.call(promise, this.#connectTimeout)
} catch (error) {
// pTimeout does not cancel: #connect() may still be running and end up
// with an opened transport nobody owns anymore
promise.then(
() =>
this.disconnect().catch(cleanupError => warn('error while cleaning up a late connection', { cleanupError })),
() => {} // the failure is already reported through `error`
)
throw error
}
}
async disconnect() {
if (this.#connectedGeneration === undefined) {
return
}
this.#connectedGeneration = undefined
const transport = this.#transport
this.#transport = undefined
if (transport === undefined) {
return
}
const queryId = this.#nextCommandQueryId
this.#nextCommandQueryId++
const buffer = Buffer.alloc(28)
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
buffer.writeInt16BE(0, 4) // no command flags for a disconnect
buffer.writeInt16BE(NBD_CMD_DISC, 6) // we want to disconnect from nbd server
buffer.writeBigUInt64BE(queryId, 8)
buffer.writeBigUInt64BE(0n, 16)
buffer.writeInt32BE(0, 24)
try {
await pTimeout.call(this._closeTransport(transport, buffer), this.#messageTimeout)
} catch (error) {
// expected when the other end is already gone, nothing actionable: the
// transport is destroyed instead of being closed gracefully
debug('error while closing the nbd transport, destroying it', { error })
this.#destroyTransport(transport)
}
}
#destroyTransport(transport) {
try {
this._destroyTransport(transport)
} catch (error) {
warn('error while destroying the nbd transport', { error })
}
}
#clearReconnectPromise = () => {
this.#reconnectingPromise = undefined
}
async #reconnect() {
await this.disconnect().catch(() => {})
await pDelay(this.#waitBeforeReconnect) // need to let the xapi clean things on its side
await this.connect()
}
async reconnect() {
// we need to ensure reconnections do not occur in parallel
if (this.#reconnectingPromise === undefined) {
this.#reconnectingPromise = pRetry(() => this.#reconnect(), {
tries: this.#reconnectRetry,
})
this.#reconnectingPromise.then(this.#clearReconnectPromise, this.#clearReconnectPromise)
}
return this.#reconnectingPromise
}
/* ---------------------------------------------------------------------------
* handshake
* ------------------------------------------------------------------------- */
/**
* Send an option to the server and check it acknowledged it.
*
* We can use individual read/write from the transport here since there is no
* concurrency during the handshake.
*
* The transport is passed explicitly since the client is not connected yet:
* it is only published once the handshake succeeded.
*
* protected: subclasses need it to negotiate their own options (NBD_OPT_STARTTLS)
*
* @param {NbdTransport} transport
* @param {number} option
* @param {Buffer} [buffer] - the payload of the option
*/
async _sendOption(transport, option, buffer = Buffer.alloc(0)) {
await this.#write(transport, OPTS_MAGIC)
await this.#writeInt32(transport, option)
await this.#writeInt32(transport, buffer.length)
await this.#write(transport, buffer)
assert.strictEqual(await this.#readInt64(transport), NBD_OPT_REPLY_MAGIC) // magic number everywhere
assert.strictEqual(await this.#readInt32(transport), option) // the option passed
assert.strictEqual(await this.#readInt32(transport), NBD_REPLY_ACK) // ACK
const length = await this.#readInt32(transport)
assert.strictEqual(length, 0) // length
}
// we can use individual read/write from the transport here since there is only one handshake at once, no concurrency
//
// it works on the transport it is given and returns the one to use for the
// transmission phase, which is not necessarily the same (TLS upgrade)
//
/**
* @param {NbdTransport} transport
* @param {number} generation - the connection attempt owning the transport
* @returns {Promise<NbdTransport>}
*/
async #handshake(transport, generation) {
assert((await this.#read(transport, 8)).equals(INIT_PASSWD))
assert((await this.#read(transport, 8)).equals(OPTS_MAGIC))
const flagsBuffer = await this.#read(transport, 2)
const flags = flagsBuffer.readInt16BE(0)
assert.strictEqual(flags & NBD_FLAG_FIXED_NEWSTYLE, NBD_FLAG_FIXED_NEWSTYLE) // only FIXED_NEWSTYLE one is supported from the server options
await this.#writeInt32(transport, NBD_FLAG_FIXED_NEWSTYLE) // client also support NBD_FLAG_C_FIXED_NEWSTYLE
// let the subclass upgrade the transport if it needs to (TLS)
const secured = await this._secureTransport(transport)
if (secured !== transport) {
this.#watchTransport(secured, generation)
transport = secured
}
// send export name we want to access.
// it's implicitly closing the negotiation phase.
await this.#write(transport, OPTS_MAGIC)
await this.#writeInt32(transport, NBD_OPT_EXPORT_NAME)
const exportNameBuffer = Buffer.from(this.#exportName)
await this.#writeInt32(transport, exportNameBuffer.length)
await this.#write(transport, exportNameBuffer)
// 8 (export size ) + 2 (flags) + 124 zero = 134
// must read all to ensure nothing stays in the buffer
const answer = await this.#read(transport, 134)
this.#exportSize = answer.readBigUInt64BE(0)
const transmissionFlags = answer.readInt16BE(8)
assert.strictEqual(transmissionFlags & NBD_FLAG_HAS_FLAGS, NBD_FLAG_HAS_FLAGS, 'NBD_FLAG_HAS_FLAGS') // must always be 1 by the norm
// note : xapi server always send NBD_FLAG_READ_ONLY (3) as a flag
return transport
}
/* ---------------------------------------------------------------------------
* raw read/write
* ------------------------------------------------------------------------- */
#getTransport() {
const transport = this.#transport
if (transport === undefined) {
const error = new Error('the nbd client is not connected')
error.code = 'NBD_NOT_CONNECTED'
throw error
}
return transport
}
#read(transport, length) {
const promise = readChunkStrict(transport.readable, length)
return pTimeout.call(promise, this.#messageTimeout)
}
#write(transport, buffer) {
let timeout
const messageTimeout = this.#messageTimeout
const { writable } = transport
return Promise.race([
new Promise((resolve, reject) => {
timeout = setTimeout(() => {
reject(new Error('timeout'))
}, messageTimeout)
}),
new Promise(resolve =>
writable.write(buffer, () => {
clearTimeout(timeout)
resolve()
})
),
])
}
async #readInt32(transport) {
const buffer = await this.#read(transport, 4)
return buffer.readInt32BE(0)
}
async #readInt64(transport) {
const buffer = await this.#read(transport, 8)
return buffer.readBigUInt64BE(0)
}
#writeInt32(transport, int) {
const buffer = Buffer.alloc(4)
buffer.writeInt32BE(int)
return this.#write(transport, buffer)
}
/* ---------------------------------------------------------------------------
* transmission
* ------------------------------------------------------------------------- */
// when one read fail ,stop everything
// (sync: it must be usable from an event handler without leaving a floating promise)
#rejectAll(error) {
this.#commandQueryBacklog.forEach(({ reject }) => {
reject(error)
})
}
async #readBlockResponse() {
// ensure at most one read occur in parallel
if (this.#waitingForResponse) {
return
}
try {
this.#waitingForResponse = true
const transport = this.#getTransport()
const buffer = await this.#read(transport, 16)
const magic = buffer.readInt32BE(0)
if (magic !== NBD_REPLY_MAGIC) {
throw new Error(`magic number for block answer is wrong : ${magic} ${NBD_REPLY_MAGIC}`)
}
const error = buffer.readInt32BE(4)
if (error !== 0) {
// @todo use error code from constants.mjs
throw new Error(`GOT ERROR CODE : ${error}`)
}
const blockQueryId = buffer.readBigUInt64BE(8)
const query = this.#commandQueryBacklog.get(blockQueryId)
if (!query) {
throw new Error(` no query associated with id ${blockQueryId}`)
}
this.#commandQueryBacklog.delete(blockQueryId)
const data = await this.#read(transport, query.size)
query.resolve(data)
this.#waitingForResponse = false
if (this.#commandQueryBacklog.size > 0) {
// it doesn't throw directly but will throw all relevant promise on failure
this.#readBlockResponse()
}
} catch (error) {
// reject all the promises
// we don't need to call readBlockResponse on failure
// since we will empty the backlog
this.#rejectAll(error)
}
}
async #readBlock(index, size) {
// we don't want to add anything in backlog while reconnecting
if (this.#reconnectingPromise) {
await this.#reconnectingPromise
}
const queryId = this.#nextCommandQueryId
this.#nextCommandQueryId++
// create and send command at once to ensure there is no concurrency issue
const buffer = Buffer.alloc(28)
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
buffer.writeInt16BE(0, 4) // no command flags for a simple block read
buffer.writeInt16BE(NBD_CMD_READ, 6) // we want to read a data block
buffer.writeBigUInt64BE(queryId, 8)
// byte offset in the raw disk
const offset = BigInt(index) * BigInt(size)
const remaining = this.#exportSize - offset
if (remaining < BigInt(size)) {
size = Number(remaining)
}
buffer.writeBigUInt64BE(offset, 16)
buffer.writeInt32BE(size, 24)
return new Promise((resolve, reject) => {
function decoratedReject(error) {
error.index = index
error.size = size
reject(error)
}
// this will handle one block response, but it can be another block
// since server does not guaranty to handle query in order
this.#commandQueryBacklog.set(queryId, {
size,
resolve,
reject: decoratedReject,
})
// really send the command to the server
this.#write(this.#getTransport(), buffer).catch(decoratedReject)
// #readBlockResponse never throws directly
// but if it fails it will reject all the promises in the backlog
this.#readBlockResponse()
})
}
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
return pRetry(() => this.#readBlock(index, size), {
tries: this.#readBlockRetries,
when: error => error.code !== 'ERR_ABORTED',
onRetry: async err => {
warn('will retry reading block ', index, err)
await this.reconnect()
},
})
}
}

View File

@@ -0,0 +1,199 @@
import assert from 'node:assert/strict'
import { PassThrough } from 'node:stream'
import { setTimeout as pSleep } from 'node:timers/promises'
import { describe, it } from 'node:test'
import AbstractNbdClient from './AbstractNbdClient.mjs'
import { serveNbd } from './tests/fake-nbd-server.mjs'
const BLOCK_SIZE = 512
// non aligned size, to check the truncated last block
const DATA = Buffer.alloc(BLOCK_SIZE * 3 + 100)
for (let i = 0; i < DATA.length; i += 4) {
DATA.writeUInt32BE(i, i)
}
// exercises AbstractNbdClient without any I/O: the fake server runs in the same
// process, on the other end of two in-memory streams
class InMemoryNbdClient extends AbstractNbdClient {
#serverOptions
/** @type {Array<object>} */
transports = []
/** @type {Array<object>} */
destroyedTransports = []
secureTransportCalls = 0
constructor({ exportname, ...serverOptions } = {}, options) {
super({ exportname }, options)
this.#serverOptions = serverOptions
}
async _openTransport() {
const toServer = new PassThrough()
const toClient = new PassThrough()
const transport = { readable: toClient, writable: toServer }
this.transports.push(transport)
// the server runs alongside the client, a failure on its side is reported
// to the client the same way a network error would be
serveNbd({ readable: toServer, writable: toClient, data: DATA, ...this.#serverOptions }).catch(error =>
toClient.destroy(error)
)
return transport
}
async _secureTransport(transport) {
this.secureTransportCalls++
return super._secureTransport(transport)
}
_destroyTransport(transport) {
this.destroyedTransports.push(transport)
super._destroyTransport(transport)
}
}
// the first connection is opened on a transport nobody ever answers on: its
// handshake stalls until the message timeout, well after connect() gave up
class StallingFirstConnectClient extends InMemoryNbdClient {
#openCalls = 0
async _openTransport() {
if (this.#openCalls++ === 0) {
const transport = { readable: new PassThrough(), writable: new PassThrough() }
this.transports.push(transport)
return transport
}
return super._openTransport()
}
}
describe('AbstractNbdClient', () => {
it('handshakes and reads blocks', async () => {
const client = new InMemoryNbdClient()
await client.connect()
try {
assert.equal(client.exportSize, BigInt(DATA.length))
assert.equal(client.connected, true)
const block = await client.readBlock(1, BLOCK_SIZE)
assert.ok(block.equals(DATA.subarray(BLOCK_SIZE, 2 * BLOCK_SIZE)))
} finally {
await client.disconnect()
}
assert.equal(client.connected, false)
})
it('truncates the last block of a non aligned export', async () => {
const client = new InMemoryNbdClient()
await client.connect()
try {
const block = await client.readBlock(3, BLOCK_SIZE)
assert.equal(block.length, DATA.length - 3 * BLOCK_SIZE)
assert.ok(block.equals(DATA.subarray(3 * BLOCK_SIZE)))
} finally {
await client.disconnect()
}
})
it('handles answers coming out of order', async () => {
const client = new InMemoryNbdClient({ answerInReverse: true })
await client.connect()
try {
const [first, second] = await Promise.all([client.readBlock(0, BLOCK_SIZE), client.readBlock(1, BLOCK_SIZE)])
assert.ok(first.equals(DATA.subarray(0, BLOCK_SIZE)))
assert.ok(second.equals(DATA.subarray(BLOCK_SIZE, 2 * BLOCK_SIZE)))
} finally {
await client.disconnect()
}
})
it('calls _secureTransport during the handshake', async () => {
const client = new InMemoryNbdClient()
await client.connect()
assert.equal(client.secureTransportCalls, 1)
await client.disconnect()
})
it('does not leak the transport when the handshake fails', async () => {
// the server expects another export name, and fails the handshake
const client = new InMemoryNbdClient({ exportname: 'wrong', exportName: 'expected' })
await assert.rejects(client.connect())
assert.equal(client.connected, false)
assert.equal(client.transports.length, 1)
assert.deepEqual(client.destroyedTransports, client.transports)
assert.equal(client.transports[0].writable.destroyed, true)
})
it('reports the errors answered by the server, after having retried', async () => {
const client = new InMemoryNbdClient(
{ errorCode: () => 5 /* EIO */ },
{ readBlockRetries: 2, reconnectRetry: 1, waitBeforeReconnect: 0 }
)
await client.connect()
try {
await assert.rejects(client.readBlock(0, BLOCK_SIZE), /ERROR CODE/)
// one transport for the initial connection, one for the retry
assert.equal(client.transports.length, 2)
} finally {
await client.disconnect()
}
})
it('does not let a connection which timed out disturb the next one', async () => {
const client = new StallingFirstConnectClient({}, { connectTimeout: 50, messageTimeout: 150 })
await assert.rejects(client.connect())
// the previous attempt is still running: it must not publish itself, nor
// touch the transport of this one
await client.connect()
try {
// let the stalled handshake reach its message timeout
await pSleep(250)
assert.equal(client.connected, true)
assert.equal(client.transports.length, 2)
// only the stalled transport has been destroyed
assert.deepEqual(client.destroyedTransports, [client.transports[0]])
const block = await client.readBlock(0, BLOCK_SIZE)
assert.ok(block.equals(DATA.subarray(0, BLOCK_SIZE)))
} finally {
await client.disconnect()
}
})
it('does not let an error on a superseded transport disturb the current one', async () => {
const client = new InMemoryNbdClient({}, { readBlockRetries: 1, waitBeforeReconnect: 0 })
await client.connect()
await client.reconnect()
try {
assert.equal(client.transports.length, 2)
const [supersededTransport] = client.transports
// the read is queued synchronously, the in memory server answers later
const promise = client.readBlock(0, BLOCK_SIZE)
// the other end of the closed transport resets it late
supersededTransport.readable.emit('error', Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }))
const block = await promise
assert.ok(block.equals(DATA.subarray(0, BLOCK_SIZE)))
// no reconnection has been triggered
assert.equal(client.transports.length, 2)
} finally {
await client.disconnect()
}
})
it('does not support getMap()', async () => {
const client = new InMemoryNbdClient()
await assert.rejects(client.getMap(), ({ code }) => code === 'NBD_MAP_UNSUPPORTED')
})
it('can be disconnected without being connected', async () => {
await new InMemoryNbdClient().disconnect()
})
})

View File

@@ -5,12 +5,18 @@ import NbdClient from '@vates/nbd-client'
* @typedef {{ offset: number, length: number }} DataRange
*/
/**
* any NBD client, whatever transport it uses
*
* @typedef {import('@vates/nbd-client').AbstractNbdClient} AbstractNbdClient
*/
/**
* @extends {RandomAccessDisk}
*/
export class NbdDisk extends RandomAccessDisk {
#nbdInfos
/** @type {NbdClient|undefined} */
/** @type {AbstractNbdClient|undefined} */
#nbdClient
/** @type {Array<DataRange> | undefined} */
@@ -25,10 +31,21 @@ export class NbdDisk extends RandomAccessDisk {
/** @type {number | undefined} */
#hasBlockPreviousIndex
constructor(nbdInfos, blockSize, { dataMap } = {}) {
/** @type {typeof AbstractNbdClient} */
#ClientClass
/**
* @param {object} nbdInfos - the settings of the client, depends on `ClientClass`
* @param {number} blockSize
* @param {object} [options]
* @param {Array<{offset: number, length: number, type: number}>} [options.dataMap] - computed through `getMap()` when not provided
* @param {typeof AbstractNbdClient} [options.ClientClass] - to talk to a NBD server through another transport
*/
constructor(nbdInfos, blockSize, { dataMap, ClientClass = NbdClient } = {}) {
super()
this.#blockSize = blockSize
this.#nbdInfos = nbdInfos
this.#ClientClass = ClientClass
this.#dataMap = dataMap && this.#processDatamap(dataMap)
}
@@ -111,7 +128,7 @@ export class NbdDisk extends RandomAccessDisk {
* @returns {Promise<void>}
*/
async init() {
this.#nbdClient = new NbdClient(this.#nbdInfos)
this.#nbdClient = new this.#ClientClass(this.#nbdInfos)
await this.#nbdClient.connect()
if (this.#dataMap === undefined) {
this.#dataMap = this.#processDatamap(await this.#nbdClient.getMap())

View File

@@ -0,0 +1,157 @@
import assert from 'node:assert'
import { spawn } from 'node:child_process'
import { pFromCallback } from 'promise-toolbox'
import { createLogger } from '@xen-orchestra/log'
import AbstractNbdClient from './AbstractNbdClient.mjs'
const { warn } = createLogger('vates:nbd-client:stdio')
// only the tail is kept: it's the part describing why the server died
const STDERR_MAX_LENGTH = 4096
/**
* NBD client talking to a server through its standard streams, like
* `nbdkit -s` or `qemu-nbd` in `--fd` mode: the queries are written to the
* server stdin and its answers are read from its stdout.
*
* The process is spawned on `connect()` and killed on `disconnect()`, and
* `reconnect()` (used by `readBlock()` retries) spawns a brand new one: the
* server must be restartable, and must serve the same content on each run.
*
* There is no TLS here: the transport is a pair of pipes, nothing to encrypt.
*
* @extends {AbstractNbdClient}
*/
export default class NbdStdioClient extends AbstractNbdClient {
#args
#command
#cwd
#env
/** @type {{ child: import('node:child_process').ChildProcess, closing: boolean, stderr: string }|undefined} */
#state
/**
* @param {object} settings
* @param {string} settings.command - the NBD server to run
* @param {ReadonlyArray<string>} [settings.args] - its arguments
* @param {string} [settings.exportname] - empty (the default export) for most single connection servers
* @param {NodeJS.ProcessEnv} [settings.env]
* @param {string} [settings.cwd]
* @param {object} [options] - see {@link AbstractNbdClient}
*/
constructor({ command, args = [], exportname, env, cwd }, options) {
super({ exportname }, options)
assert.strictEqual(typeof command, 'string', 'command must be a string')
this.#command = command
this.#args = args
this.#env = env
this.#cwd = cwd
}
/**
* the tail of what the server wrote on stderr since it was spawned, useful to
* report why it failed
*
* @returns {string}
*/
get stderr() {
return this.#state?.stderr ?? ''
}
/**
* the pid of the current server process, undefined if it's not running
*
* @returns {number|undefined}
*/
get pid() {
return this.#state?.child.pid
}
async _openTransport() {
const child = spawn(this.#command, this.#args, {
cwd: this.#cwd,
env: this.#env,
// the protocol goes through stdin/stdout, stderr is only used for diagnostics
stdio: ['pipe', 'pipe', 'pipe'],
})
const state = { child, closing: false, stderr: '' }
this.#state = state
child.stderr.setEncoding('utf8')
child.stderr.on('data', chunk => {
state.stderr = (state.stderr + chunk).slice(-STDERR_MAX_LENGTH)
})
// spawn failures (ENOENT, EACCES, ...) are reported asynchronously
await new Promise((resolve, reject) => {
const onError = error => {
error.command = this.#command
reject(error)
}
child.once('error', onError)
child.once('spawn', () => {
child.removeListener('error', onError)
// from now on, an error on the process itself (a failed kill for
// example) must not be thrown as an uncaught exception
child.on('error', error => warn('error on the nbd server process', { error, command: this.#command }))
resolve()
})
})
child.once('exit', (code, signal) => {
if (state.closing) {
// expected: disconnect() asked it to quit and is waiting for this
return
}
const error = new Error(`the nbd server process exited (code: ${code}, signal: ${signal})`)
error.code = 'NBD_SERVER_EXITED'
error.exitCode = code
error.signal = signal
error.command = this.#command
error.stderr = state.stderr
warn('the nbd server process exited unexpectedly', { error })
// make the pending and future reads fail with this error instead of
// waiting for the message timeout on a stream that will never answer
child.stdout.destroy(error)
child.stdin.destroy()
})
return { readable: child.stdout, writable: child.stdin, state }
}
async _closeTransport(transport, lastMessage) {
const { state } = transport
state.closing = true
// hand over NBD_CMD_DISC, then close stdin: that's how the server knows
// it can quit
await pFromCallback(cb => transport.writable.end(lastMessage, cb))
// don't let the server block on a write while we wait for its exit
transport.readable.resume()
const { child } = state
if (child.exitCode === null && child.signalCode === null) {
await new Promise(resolve => child.once('exit', () => resolve()))
}
}
_destroyTransport(transport) {
const { state } = transport
const { child } = state
// we're the one tearing it down: the exit that follows is expected, not a
// crash, and destroying the streams can be enough to make the server quit
state.closing = true
transport.readable.destroy()
transport.writable.destroy()
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL')
}
}
// getMap() is not implemented: `nbdinfo` needs an URI or a server supporting
// systemd socket activation, and this one only speaks through its standard
// streams. Compute the map on the caller side and pass it explicitly (
// `new NbdDisk(infos, blockSize, { dataMap })`).
}

View File

@@ -0,0 +1,197 @@
import { once } from 'node:events'
import { request as httpRequest } from 'node:http'
import { request as httpsRequest } from 'node:https'
import { isIPv6, Socket } from 'node:net'
import { connect } from 'node:tls'
import { spawn } from 'node:child_process'
import AbstractNbdClient from './AbstractNbdClient.mjs'
import { NBD_DEFAULT_PORT, NBD_OPT_STARTTLS } from './constants.mjs'
/**
* NBD client talking to a server over TCP, optionally upgrading the connection
* to TLS during the handshake.
*
* @extends {AbstractNbdClient}
*/
export default class NbdTcpClient extends AbstractNbdClient {
#httpProxy
#proxyRejectUnauthorized
#proxyTimeout
#serverAddress
#serverCert
#serverPort
/**
* @param {object} settings
* @param {string} settings.address
* @param {number} [settings.port]
* @param {string} [settings.exportname]
* @param {string} [settings.cert] - PEM certificate, enables TLS when set
* @param {string} [settings.httpProxy] - URL of an HTTP(S) proxy, the connection is tunneled through it with `CONNECT`
* @param {boolean} [settings.proxyRejectUnauthorized=true] - whether to check the certificate of an HTTPS proxy
* @param {number} [settings.proxyTimeout=6e4] - delay in ms after which an unanswered `CONNECT` is aborted
* @param {object} [options] - see {@link AbstractNbdClient}
*/
constructor(
{
address,
port = NBD_DEFAULT_PORT,
exportname,
cert,
httpProxy,
proxyRejectUnauthorized = true,
proxyTimeout = 6e4,
},
options
) {
super({ exportname }, options)
// other proxy protocols (e.g. SOCKS) are not supported for now
if (httpProxy !== undefined) {
const { protocol } = new URL(httpProxy)
if (protocol !== 'http:' && protocol !== 'https:') {
const error = new Error(`unsupported proxy protocol ${protocol}, only http: and https: are supported`)
error.code = 'NBD_PROXY_UNSUPPORTED_PROTOCOL'
throw error
}
}
this.#serverAddress = address
this.#serverPort = port
this.#serverCert = cert
this.#httpProxy = httpProxy
this.#proxyRejectUnauthorized = proxyRejectUnauthorized
this.#proxyTimeout = proxyTimeout
}
/**
* Open a raw TCP tunnel to the NBD server through an HTTP proxy
*
* https://datatracker.ietf.org/doc/html/rfc9110#section-9.3.6
*
* @returns {Promise<import('node:net').Socket>}
*/
async #connectThroughHttpProxy() {
const proxy = new URL(this.#httpProxy)
const isHttps = proxy.protocol === 'https:'
const address = this.#serverAddress
const target = `${isIPv6(address) ? `[${address}]` : address}:${this.#serverPort}`
const headers = { host: target }
if (proxy.username !== '' || proxy.password !== '') {
const credentials = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`
headers['proxy-authorization'] = `Basic ${Buffer.from(credentials).toString('base64')}`
}
const req = (isHttps ? httpsRequest : httpRequest)({
agent: false,
headers,
// URL keeps the brackets around IPv6 addresses
hostname: proxy.hostname.replace(/^\[(.*)\]$/, '$1'),
method: 'CONNECT',
path: target,
port: proxy.port,
rejectUnauthorized: this.#proxyRejectUnauthorized,
timeout: this.#proxyTimeout,
})
// `timeout` only emits an event, the request must be aborted explicitly
req.on('timeout', () => {
const error = new Error(`HTTP proxy did not answer the CONNECT to ${target} in time`)
error.code = 'NBD_PROXY_CONNECT_TIMEOUT'
req.destroy(error)
})
req.end()
// the NBD server may already have sent some data, it is in `head`
const [res, socket, head] = await once(req, 'connect')
// the tunnel is established, NBD has its own timeouts
socket.setTimeout(0)
if (res.statusCode !== 200) {
socket.destroy()
const error = new Error(`HTTP proxy refused to connect to ${target}: ${res.statusCode} ${res.statusMessage}`)
error.code = 'NBD_PROXY_CONNECT_FAILED'
throw error
}
if (head.length !== 0) {
socket.unshift(head)
}
return socket
}
// mandatory , at least to start the handshake: the connection always starts
// unsecured, and is upgraded to TLS during the handshake
async _openTransport() {
if (this.#httpProxy !== undefined) {
const socket = await this.#connectThroughHttpProxy()
return { readable: socket, writable: socket }
}
const socket = new Socket()
await new Promise((resolve, reject) => {
socket.connect(this.#serverPort, this.#serverAddress)
socket.once('error', reject)
socket.once('connect', () => {
socket.removeListener('error', reject)
resolve()
})
})
return { readable: socket, writable: socket }
}
async _secureTransport(transport) {
if (this.#serverCert === undefined) {
return transport
}
await this._sendOption(transport, NBD_OPT_STARTTLS)
const secured = await new Promise((resolve, reject) => {
const socket = connect({
socket: transport.writable,
rejectUnauthorized: false,
cert: this.#serverCert,
})
socket.once('error', reject)
socket.once('secureConnect', () => {
socket.removeListener('error', reject)
resolve(socket)
})
})
return { readable: secured, writable: secured }
}
/**
* @param {AbortSignal} [signal]
* @returns {Promise<{ offset: number, length: number, type: number }[]>}
*/
/* async */ getMap(signal) {
return new Promise((resolve, reject) => {
const process = spawn('nbdinfo', [
'--json',
'--map',
`nbd://${this.#serverAddress}:${this.#serverPort}/${encodeURIComponent(this.exportName)}`,
])
let text = ''
let errText = ''
process.stdout.on('data', data => (text += data))
process.stderr.on('data', data => (errText += data))
const onAbort = () => {
process.kill()
reject(signal.reason)
}
signal?.addEventListener('abort', onAbort, { once: true })
process.on('error', err => {
signal?.removeEventListener('abort', onAbort)
reject(err)
})
process.on('close', code => {
signal?.removeEventListener('abort', onAbort)
if (code !== 0) {
return reject(new Error(`Error during getMap (code: ${code}): ${errText}`))
}
try {
const json = JSON.parse(text)
resolve(json)
} catch (error) {
reject(new Error(`${error} ${text}`))
}
})
})
}
}

View File

@@ -0,0 +1,107 @@
import assert from 'node:assert/strict'
import { after, before, describe, it } from 'node:test'
import { createServer as createHttpServer } from 'node:http'
import { connect, createServer as createTcpServer } from 'node:net'
import { once } from 'node:events'
import NbdTcpClient from './NbdTcpClient.mjs'
import { serveNbd } from './tests/fake-nbd-server.mjs'
const BLOCK_SIZE = 64 * 1024
const DATA = Buffer.alloc(4 * BLOCK_SIZE)
for (let i = 0; i < DATA.length; i++) {
DATA[i] = i % 251
}
const CREDENTIALS = 'Basic ' + Buffer.from('user:p@ss').toString('base64')
describe('NbdTcpClient through an HTTP proxy', () => {
let nbdServer, proxyServer, nbdPort, proxyPort
const connectRequests = []
before(async () => {
nbdServer = createTcpServer(socket => {
serveNbd({ readable: socket, writable: socket, data: DATA }).catch(() => {})
})
nbdServer.listen(0, '127.0.0.1')
await once(nbdServer, 'listening')
nbdPort = nbdServer.address().port
// minimal CONNECT proxy, similar to the XO Proxy one
proxyServer = createHttpServer()
proxyServer.on('connect', (req, clientSocket, head) => {
connectRequests.push(req.url)
if (req.headers['proxy-authorization'] !== CREDENTIALS) {
clientSocket.end('HTTP/1.1 407 Proxy Authentication Required\r\n\r\n')
return
}
const { hostname, port } = new URL('http://' + req.url)
const serverSocket = connect(port, hostname, () => {
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
serverSocket.write(head)
serverSocket.pipe(clientSocket).pipe(serverSocket)
})
serverSocket.on('error', () => clientSocket.destroy())
clientSocket.on('error', () => serverSocket.destroy())
})
proxyServer.listen(0, '127.0.0.1')
await once(proxyServer, 'listening')
proxyPort = proxyServer.address().port
})
after(() => {
proxyServer.closeAllConnections()
proxyServer.close()
nbdServer.close()
})
it('tunnels the NBD connection with CONNECT', async () => {
const client = new NbdTcpClient({
address: '127.0.0.1',
port: nbdPort,
httpProxy: `http://user:p%40ss@127.0.0.1:${proxyPort}`,
})
await client.connect()
try {
assert.equal(client.exportSize, BigInt(DATA.length))
const block = await client.readBlock(2, BLOCK_SIZE)
assert.deepEqual(block, DATA.subarray(2 * BLOCK_SIZE, 3 * BLOCK_SIZE))
} finally {
await client.disconnect()
}
assert.equal(connectRequests.at(-1), `127.0.0.1:${nbdPort}`)
})
it('fails when the proxy refuses the connection', async () => {
const client = new NbdTcpClient({
address: '127.0.0.1',
port: nbdPort,
httpProxy: `http://user:wrong@127.0.0.1:${proxyPort}`,
})
await assert.rejects(client.connect(), { code: 'NBD_PROXY_CONNECT_FAILED' })
await client.disconnect().catch(() => {})
})
it('aborts the CONNECT when the proxy does not answer', async () => {
// accepts the TCP connection but never answers
const silentProxy = createTcpServer(() => {})
silentProxy.listen(0, '127.0.0.1')
await once(silentProxy, 'listening')
try {
const client = new NbdTcpClient({
address: '127.0.0.1',
port: nbdPort,
httpProxy: `http://127.0.0.1:${silentProxy.address().port}`,
proxyTimeout: 50,
})
await assert.rejects(client.connect(), { code: 'NBD_PROXY_CONNECT_TIMEOUT' })
} finally {
silentProxy.close()
}
})
it('rejects unsupported proxy protocols', () => {
assert.throws(() => new NbdTcpClient({ address: '127.0.0.1', httpProxy: 'socks5://127.0.0.1:1080' }), {
code: 'NBD_PROXY_UNSUPPORTED_PROTOCOL',
})
})
})

View File

@@ -14,9 +14,15 @@ npm install --save @vates/nbd-client
## Usage
### `new NdbClient({address, exportname, secure = true, port = 10809})`
This package provides a NBD client, in two flavors sharing the same protocol
implementation (`AbstractNbdClient`): over the network, or over the standard
streams of a NBD server run as a child process.
create a new nbd client
### `new NbdClient({address, exportname, cert, port = 10809})`
Create a new nbd client connecting to a server over TCP.
`NbdClient` is also exported as `NbdTcpClient`.
```js
import NbdClient from '@vates/nbd-client'
@@ -31,6 +37,58 @@ const block = await client.readBlock(blockIndex, BlockSize)
await client.disconnect()
```
### `new NbdStdioClient({command, args, exportname})`
Create a new nbd client talking to a server through its standard streams, like
`nbdkit --single` or any server reading its queries on stdin and writing its
answers on stdout.
The process is spawned by `connect()` and stopped by `disconnect()`.
```js
import { NbdStdioClient } from '@vates/nbd-client'
const client = new NbdStdioClient({
command: 'nbdkit',
args: ['--single', '--exit-with-parent', '--read-only', 'file', '/path/to/disk.raw'],
exportname: '', // optional, the default export of the server
})
await client.connect()
const block = await client.readBlock(blockIndex, BlockSize)
await client.disconnect()
```
There is no TLS here: the transport is a pair of pipes, there is nothing to
encrypt.
`readBlock()` retries on error, and a retry reconnects: with this client that
means killing the server and spawning a new one, so the command must be
restartable and must serve the same content on each run.
`getMap()` is not implemented, since `nbdinfo` needs either an URI or a server
supporting systemd socket activation: compute the map on the caller side and
pass it explicitly, for example `new NbdDisk(infos, blockSize, { dataMap })`.
The tail of the server stderr is available as `client.stderr` and attached to
the errors raised when the server dies unexpectedly.
### Writing another client
Subclass `AbstractNbdClient` and implement the transport:
- `_openTransport()`: **required**, returns `{ readable, writable }`, called on
every (re)connection.
- `_secureTransport(transport)`: optional, called during the handshake, the only
place where the transport can be upgraded (`NBD_OPT_STARTTLS`).
- `_closeTransport(transport, lastMessage)`: optional, must hand over
`lastMessage` (`NBD_CMD_DISC`) before closing.
- `_destroyTransport(transport)`: optional, forceful cleanup, must not throw.
- `getMap(signal)`: optional, the base implementation rejects with the code
`NBD_MAP_UNSUPPORTED`.
Any extra property set on the transport object is passed back to
`_closeTransport()` and `_destroyTransport()`.
## Contributions
Contributions are _very_ welcomed, either on the documentation or on

View File

@@ -1,408 +1,6 @@
import assert from 'node:assert'
import { Socket } from 'node:net'
import { connect } from 'node:tls'
import { pRetry, pDelay, pTimeout, pFromCallback } from 'promise-toolbox'
import { readChunkStrict } from '@vates/read-chunk'
import { createLogger } from '@xen-orchestra/log'
import {
INIT_PASSWD,
NBD_CMD_READ,
NBD_DEFAULT_BLOCK_SIZE,
NBD_DEFAULT_PORT,
NBD_FLAG_FIXED_NEWSTYLE,
NBD_FLAG_HAS_FLAGS,
NBD_OPT_EXPORT_NAME,
NBD_OPT_REPLY_MAGIC,
NBD_OPT_STARTTLS,
NBD_REPLY_ACK,
NBD_REPLY_MAGIC,
NBD_REQUEST_MAGIC,
OPTS_MAGIC,
NBD_CMD_DISC,
} from './constants.mjs'
import { spawn } from 'node:child_process'
// the TCP client is the historical default export of this package
export { default } from './NbdTcpClient.mjs'
const { warn } = createLogger('vates:nbd-client')
// documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
export default class NbdClient {
#serverAddress
#serverCert
#serverPort
#serverSocket
#exportName
#exportSize
#waitBeforeReconnect
#readBlockRetries
#reconnectRetry
#connectTimeout
#messageTimeout
// AFAIK, there is no guaranty the server answers in the same order as the queries
// so we handle a backlog of command waiting for response and handle concurrency manually
#waitingForResponse // there is already a listener waiting for a response
#nextCommandQueryId = BigInt(0)
#commandQueryBacklog // map of command waiting for a response queryId => { size/*in byte*/, resolve, reject}
#connected = false
#reconnectingPromise
constructor(
{ address, port = NBD_DEFAULT_PORT, exportname, cert },
{
connectTimeout = 6e4,
messageTimeout = 6e4,
waitBeforeReconnect = 1e3,
readBlockRetries = 5,
reconnectRetry = 5,
} = {}
) {
this.#serverAddress = address
this.#serverPort = port
this.#exportName = exportname
this.#serverCert = cert
this.#waitBeforeReconnect = waitBeforeReconnect
this.#readBlockRetries = readBlockRetries
this.#reconnectRetry = reconnectRetry
this.#connectTimeout = connectTimeout
this.#messageTimeout = messageTimeout
}
get exportSize() {
return this.#exportSize
}
async #tlsConnect() {
return new Promise((resolve, reject) => {
this.#serverSocket = connect({
socket: this.#serverSocket,
rejectUnauthorized: false,
cert: this.#serverCert,
})
this.#serverSocket.once('error', reject)
this.#serverSocket.once('secureConnect', () => {
this.#serverSocket.removeListener('error', reject)
resolve()
})
})
}
// mandatory , at least to start the handshake
async #unsecureConnect() {
this.#serverSocket = new Socket()
return new Promise((resolve, reject) => {
this.#serverSocket.connect(this.#serverPort, this.#serverAddress)
this.#serverSocket.once('error', reject)
this.#serverSocket.once('connect', () => {
this.#serverSocket.removeListener('error', reject)
resolve()
})
})
}
async #connect() {
// first we connect to the server without tls, and then we upgrade the connection
// to tls during the handshake
await this.#unsecureConnect()
await this.#handshake()
this.#connected = true
// reset internal state if we reconnected a nbd client
this.#commandQueryBacklog = new Map()
this.#waitingForResponse = false
}
async connect() {
return pTimeout.call(this.#connect(), this.#connectTimeout)
}
async disconnect() {
if (!this.#connected) {
return
}
this.#connected = false
const socket = this.#serverSocket
const queryId = this.#nextCommandQueryId
this.#nextCommandQueryId++
const buffer = Buffer.alloc(28)
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
buffer.writeInt16BE(0, 4) // no command flags for a disconnect
buffer.writeInt16BE(NBD_CMD_DISC, 6) // we want to disconnect from nbd server
buffer.writeBigUInt64BE(queryId, 8)
buffer.writeBigUInt64BE(0n, 16)
buffer.writeInt32BE(0, 24)
const promise = pFromCallback(cb => {
socket.end(buffer, 'utf8', cb)
})
try {
await pTimeout.call(promise, this.#messageTimeout)
} catch (error) {
socket.destroy()
}
this.#serverSocket = undefined
this.#connected = false
}
#clearReconnectPromise = () => {
this.#reconnectingPromise = undefined
}
async #reconnect() {
await this.disconnect().catch(() => {})
await pDelay(this.#waitBeforeReconnect) // need to let the xapi clean things on its side
await this.connect()
}
async reconnect() {
// we need to ensure reconnections do not occur in parallel
if (this.#reconnectingPromise === undefined) {
this.#reconnectingPromise = pRetry(() => this.#reconnect(), {
tries: this.#reconnectRetry,
})
this.#reconnectingPromise.then(this.#clearReconnectPromise, this.#clearReconnectPromise)
}
return this.#reconnectingPromise
}
// we can use individual read/write from the socket here since there is no concurrency
async #sendOption(option, buffer = Buffer.alloc(0)) {
await this.#write(OPTS_MAGIC)
await this.#writeInt32(option)
await this.#writeInt32(buffer.length)
await this.#write(buffer)
assert.strictEqual(await this.#readInt64(), NBD_OPT_REPLY_MAGIC) // magic number everywhere
assert.strictEqual(await this.#readInt32(), option) // the option passed
assert.strictEqual(await this.#readInt32(), NBD_REPLY_ACK) // ACK
const length = await this.#readInt32()
assert.strictEqual(length, 0) // length
}
// we can use individual read/write from the socket here since there is only one handshake at once, no concurrency
async #handshake() {
assert((await this.#read(8)).equals(INIT_PASSWD))
assert((await this.#read(8)).equals(OPTS_MAGIC))
const flagsBuffer = await this.#read(2)
const flags = flagsBuffer.readInt16BE(0)
assert.strictEqual(flags & NBD_FLAG_FIXED_NEWSTYLE, NBD_FLAG_FIXED_NEWSTYLE) // only FIXED_NEWSTYLE one is supported from the server options
await this.#writeInt32(NBD_FLAG_FIXED_NEWSTYLE) // client also support NBD_FLAG_C_FIXED_NEWSTYLE
if (this.#serverCert !== undefined) {
// upgrade socket to TLS if needed
await this.#sendOption(NBD_OPT_STARTTLS)
await this.#tlsConnect()
}
// send export name we want to access.
// it's implicitly closing the negotiation phase.
await this.#write(OPTS_MAGIC)
await this.#writeInt32(NBD_OPT_EXPORT_NAME)
const exportNameBuffer = Buffer.from(this.#exportName)
await this.#writeInt32(exportNameBuffer.length)
await this.#write(exportNameBuffer)
// 8 (export size ) + 2 (flags) + 124 zero = 134
// must read all to ensure nothing stays in the buffer
const answer = await this.#read(134)
this.#exportSize = answer.readBigUInt64BE(0)
const transmissionFlags = answer.readInt16BE(8)
assert.strictEqual(transmissionFlags & NBD_FLAG_HAS_FLAGS, NBD_FLAG_HAS_FLAGS, 'NBD_FLAG_HAS_FLAGS') // must always be 1 by the norm
// note : xapi server always send NBD_FLAG_READ_ONLY (3) as a flag
}
#read(length) {
const promise = readChunkStrict(this.#serverSocket, length)
return pTimeout.call(promise, this.#messageTimeout)
}
#write(buffer) {
let timeout
const messageTimeout = this.#messageTimeout
const socket = this.#serverSocket
return Promise.race([
new Promise((resolve, reject) => {
timeout = setTimeout(() => {
reject(new Error('timeout'))
}, messageTimeout)
}),
new Promise(resolve =>
socket.write(buffer, () => {
clearTimeout(timeout)
resolve()
})
),
])
}
async #readInt32() {
const buffer = await this.#read(4)
return buffer.readInt32BE(0)
}
async #readInt64() {
const buffer = await this.#read(8)
return buffer.readBigUInt64BE(0)
}
#writeInt32(int) {
const buffer = Buffer.alloc(4)
buffer.writeInt32BE(int)
return this.#write(buffer)
}
// when one read fail ,stop everything
async #rejectAll(error) {
this.#commandQueryBacklog.forEach(({ reject }) => {
reject(error)
})
}
async #readBlockResponse() {
// ensure at most one read occur in parallel
if (this.#waitingForResponse) {
return
}
try {
this.#waitingForResponse = true
const buffer = await this.#read(16)
const magic = buffer.readInt32BE(0)
if (magic !== NBD_REPLY_MAGIC) {
throw new Error(`magic number for block answer is wrong : ${magic} ${NBD_REPLY_MAGIC}`)
}
const error = buffer.readInt32BE(4)
if (error !== 0) {
// @todo use error code from constants.mjs
throw new Error(`GOT ERROR CODE : ${error}`)
}
const blockQueryId = buffer.readBigUInt64BE(8)
const query = this.#commandQueryBacklog.get(blockQueryId)
if (!query) {
throw new Error(` no query associated with id ${blockQueryId}`)
}
this.#commandQueryBacklog.delete(blockQueryId)
const data = await this.#read(query.size)
query.resolve(data)
this.#waitingForResponse = false
if (this.#commandQueryBacklog.size > 0) {
// it doesn't throw directly but will throw all relevant promise on failure
this.#readBlockResponse()
}
} catch (error) {
// reject all the promises
// we don't need to call readBlockResponse on failure
// since we will empty the backlog
await this.#rejectAll(error)
}
}
async #readBlock(index, size) {
// we don't want to add anything in backlog while reconnecting
if (this.#reconnectingPromise) {
await this.#reconnectingPromise
}
const queryId = this.#nextCommandQueryId
this.#nextCommandQueryId++
// create and send command at once to ensure there is no concurrency issue
const buffer = Buffer.alloc(28)
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
buffer.writeInt16BE(0, 4) // no command flags for a simple block read
buffer.writeInt16BE(NBD_CMD_READ, 6) // we want to read a data block
buffer.writeBigUInt64BE(queryId, 8)
// byte offset in the raw disk
const offset = BigInt(index) * BigInt(size)
const remaining = this.#exportSize - offset
if (remaining < BigInt(size)) {
size = Number(remaining)
}
buffer.writeBigUInt64BE(offset, 16)
buffer.writeInt32BE(size, 24)
return new Promise((resolve, reject) => {
function decoratedReject(error) {
error.index = index
error.size = size
reject(error)
}
// this will handle one block response, but it can be another block
// since server does not guaranty to handle query in order
this.#commandQueryBacklog.set(queryId, {
size,
resolve,
reject: decoratedReject,
})
// really send the command to the server
this.#write(buffer).catch(decoratedReject)
// #readBlockResponse never throws directly
// but if it fails it will reject all the promises in the backlog
this.#readBlockResponse()
})
}
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
return pRetry(() => this.#readBlock(index, size), {
tries: this.#readBlockRetries,
when: error => error.code !== 'ERR_ABORTED',
onRetry: async err => {
warn('will retry reading block ', index, err)
await this.reconnect()
},
})
}
/**
* returns the map of the file with holes, zeros and data, useful to handle efficiently sparse source
* to implement this internally: use structure response if the server supports it, and then ask for BLOCK_STATUS *
*
* @returns {Promise<{ offset: number, length: number, type: number }[]>}
* A promise that resolves to an array where each object represents a segment:
* - `offset` — The byte offset from the start.
* - `length` — The size of the segment in bytes.
* - `type` — A numeric code indicating the segment type ( 0 means data).
*/
/* async */ getMap(signal) {
return new Promise((resolve, reject) => {
const process = spawn('nbdinfo', [
'--json',
'--map',
`nbd://${this.#serverAddress}:${this.#serverPort}/${encodeURIComponent(this.#exportName)}`,
])
let text = ''
let errText = ''
process.stdout.on('data', data => (text += data))
process.stderr.on('data', data => (errText += data))
const onAbort = () => {
process.kill()
reject(signal.reason)
}
signal?.addEventListener('abort', onAbort, { once: true })
process.on('error', err => {
signal?.removeEventListener('abort', onAbort)
reject(err)
})
process.on('close', code => {
signal?.removeEventListener('abort', onAbort)
if (code !== 0) {
return reject(new Error(`Error during getMap (code: ${code}): ${errText}`))
}
try {
const json = JSON.parse(text)
resolve(json)
} catch (error) {
reject(new Error(`${error} ${text}`))
}
})
})
}
}
export { default as AbstractNbdClient } from './AbstractNbdClient.mjs'
export { default as NbdStdioClient } from './NbdStdioClient.mjs'
export { default as NbdTcpClient } from './NbdTcpClient.mjs'

View File

@@ -13,7 +13,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "3.5.1",
"version": "3.7.0",
"engines": {
"node": ">=14.0"
},
@@ -21,11 +21,11 @@
"dependencies": {
"@vates/async-each": "^1.0.3",
"@vates/read-chunk": "^1.2.1",
"@xen-orchestra/async-map": "^0.1.4",
"@xen-orchestra/disk-transform": "^1.3.3",
"@xen-orchestra/async-map": "^0.1.5",
"@xen-orchestra/disk-transform": "^1.3.4",
"@xen-orchestra/log": "^0.7.2",
"promise-toolbox": "^0.21.0",
"xen-api": "^4.9.0"
"xen-api": "^5.1.0"
},
"devDependencies": {
"tap": "^18.7.0",

View File

@@ -0,0 +1,132 @@
import assert from 'node:assert'
import { pFromCallback } from 'promise-toolbox'
import { readChunk, readChunkStrict } from '@vates/read-chunk'
import {
INIT_PASSWD,
NBD_CMD_DISC,
NBD_CMD_READ,
NBD_FLAG_FIXED_NEWSTYLE,
NBD_FLAG_HAS_FLAGS,
NBD_FLAG_READ_ONLY,
NBD_OPT_EXPORT_NAME,
NBD_OPT_REPLY_MAGIC,
NBD_REPLY_ACK,
NBD_REPLY_MAGIC,
NBD_REQUEST_MAGIC,
OPTS_MAGIC,
} from '../constants.mjs'
const NBD_EINVAL = 22
/**
* Minimal read-only newstyle NBD server, used by the tests only.
*
* It is transport agnostic on purpose: the unit tests drive it through two
* in-memory `PassThrough`, the integration tests through the standard streams
* of a real process.
*
* @param {object} options
* @param {import('node:stream').Readable} options.readable - where the client queries are read from
* @param {import('node:stream').Writable} options.writable - where the answers are written to
* @param {Buffer} options.data - the content of the export
* @param {string} [options.exportName] - the export name the client must ask for
* @param {boolean} [options.answerInReverse] - answer by pairs, in reverse order, to check the client handles out of order answers
* @param {(request: {offset: bigint, length: number, index: number}) => number} [options.errorCode] - non zero to answer an error to this read
* @returns {Promise<void>} resolves when the client disconnected or closed the connection
*/
export async function serveNbd({ readable, writable, data, exportName = '', answerInReverse = false, errorCode }) {
const write = buffer => pFromCallback(cb => writable.write(buffer, cb))
// handshake: server flags
await write(INIT_PASSWD)
await write(OPTS_MAGIC)
const serverFlags = Buffer.alloc(2)
serverFlags.writeInt16BE(NBD_FLAG_FIXED_NEWSTYLE)
await write(serverFlags)
// client flags
await readChunkStrict(readable, 4)
// options, until the client selects an export
let selected = false
do {
assert.ok((await readChunkStrict(readable, 8)).equals(OPTS_MAGIC), 'option magic')
const option = (await readChunkStrict(readable, 4)).readInt32BE(0)
const length = (await readChunkStrict(readable, 4)).readInt32BE(0)
const payload = length > 0 ? await readChunkStrict(readable, length) : Buffer.alloc(0)
if (option === NBD_OPT_EXPORT_NAME) {
assert.strictEqual(payload.toString(), exportName, 'export name')
// 8 (size) + 2 (transmission flags) + 124 zeroes
const answer = Buffer.alloc(134)
answer.writeBigUInt64BE(BigInt(data.length), 0)
answer.writeInt16BE(NBD_FLAG_HAS_FLAGS | NBD_FLAG_READ_ONLY, 8)
await write(answer)
selected = true
} else {
// ack everything else
const answer = Buffer.alloc(20)
answer.writeBigUInt64BE(NBD_OPT_REPLY_MAGIC, 0)
answer.writeInt32BE(option, 8)
answer.writeInt32BE(NBD_REPLY_ACK, 12)
answer.writeInt32BE(0, 16)
await write(answer)
}
} while (!selected)
// transmission
let index = 0
let pending = []
const answer = async ({ handle, offset, length, index }, forcedCode) => {
const code = forcedCode ?? errorCode?.({ handle, offset, length, index }) ?? 0
const header = Buffer.alloc(16)
header.writeInt32BE(NBD_REPLY_MAGIC, 0)
header.writeInt32BE(code, 4)
header.writeBigUInt64BE(handle, 8)
await write(header)
if (code === 0) {
await write(data.subarray(Number(offset), Number(offset) + length))
}
}
const flush = async () => {
const requests = pending
pending = []
for (const request of requests.reverse()) {
await answer(request)
}
}
while (true) {
const request = await readChunk(readable, 28)
if (request === null || request.length < 28) {
// the client closed the connection without a NBD_CMD_DISC
break
}
assert.strictEqual(request.readInt32BE(0), NBD_REQUEST_MAGIC, 'request magic')
const type = request.readInt16BE(6)
const handle = request.readBigUInt64BE(8)
if (type === NBD_CMD_DISC) {
await flush()
break
}
if (type !== NBD_CMD_READ) {
await answer({ handle, offset: 0n, length: 0, index: index++ }, NBD_EINVAL)
continue
}
const read = {
handle,
offset: request.readBigUInt64BE(16),
length: request.readInt32BE(24),
index: index++,
}
pending.push(read)
if (!answerInReverse || pending.length === 2) {
await flush()
}
}
}

View File

@@ -0,0 +1,114 @@
import assert from 'node:assert'
import fs from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { pFromCallback } from 'promise-toolbox'
import { test } from 'tap'
import tmp from 'tmp'
import NbdStdioClient from '../NbdStdioClient.mjs'
// nbdkit in single connection mode: the real thing this client targets
const NBDKIT = path => ({
command: 'nbdkit',
args: ['--single', '--exit-with-parent', '--read-only', 'file', path],
})
// a tiny NBD server we can ask to die in the middle of the transfer, which
// nbdkit won't do
const FLAKY_SERVER = fileURLToPath(new URL('./stdio-nbd-server.mjs', import.meta.url))
const CHUNK_SIZE = 64 * 1024
const FILE_SIZE = CHUNK_SIZE * 8 + 1232 // non aligned file size
async function createTempFile(size) {
const tmpPath = await pFromCallback(cb => tmp.file(cb))
const data = Buffer.alloc(size)
for (let i = 0; i < size; i += 4) {
data.writeUInt32BE(i, i)
}
await fs.writeFile(tmpPath, data)
return tmpPath
}
function checkBlock(tap, block, index) {
tap.equal(block.length, Math.min(CHUNK_SIZE, FILE_SIZE - index * CHUNK_SIZE), `block ${index} length`)
let ok = true
for (let i = 0; i < block.length; i += 4) {
ok = ok && block.readUInt32BE(i) === index * CHUNK_SIZE + i
}
tap.ok(ok, `block ${index} content`)
}
async function readWholeExport(tap, client) {
for (let index = 0; index < Math.ceil(FILE_SIZE / CHUNK_SIZE); index++) {
checkBlock(tap, await client.readBlock(index, CHUNK_SIZE), index)
}
}
const isAlive = pid => {
try {
process.kill(pid, 0)
return true
} catch (error) {
return false
}
}
test('it reads an export served by nbdkit on stdin/stdout', async tap => {
const path = await createTempFile(FILE_SIZE)
const client = new NbdStdioClient(NBDKIT(path))
await client.connect()
const { pid } = client
tap.equal(client.exportSize, BigInt(FILE_SIZE))
await readWholeExport(tap, client)
await client.disconnect()
tap.notOk(isAlive(pid), 'the server process is stopped after disconnect')
// double disconnection shouldn't pose any problem
await client.disconnect()
await fs.unlink(path)
})
test('it fails when the server does not serve the requested export', async tap => {
const path = await createTempFile(CHUNK_SIZE)
// the file plugin of nbdkit ignores the export name, so use our own server here
const client = new NbdStdioClient(
{
command: process.execPath,
args: [FLAKY_SERVER, path, '--export-name=MY_SECRET_EXPORT'],
exportname: 'NOPE',
},
{ connectTimeout: 5e3 }
)
await assert.rejects(client.connect())
tap.match(client.stderr, /NOPE/, 'the stderr of the server is captured')
await fs.unlink(path)
})
test('it recovers from a server dying in the middle of the transfer', async tap => {
const path = await createTempFile(FILE_SIZE)
const client = new NbdStdioClient(
{ command: process.execPath, args: [FLAKY_SERVER, path, '--die-after=3'] },
{ waitBeforeReconnect: 100 }
)
await client.connect()
await readWholeExport(tap, client)
await client.disconnect()
await fs.unlink(path)
})
test('it fails cleanly when the server cannot be spawned', async () => {
const client = new NbdStdioClient({ command: '/does/not/exist' })
await assert.rejects(client.connect(), ({ code, command }) => code === 'ENOENT' && command === '/does/not/exist')
// nothing to disconnect from
await client.disconnect()
})
test('it does not implement getMap', async () => {
const client = new NbdStdioClient({ command: 'nbdkit' })
await assert.rejects(client.getMap(), ({ code }) => code === 'NBD_MAP_UNSUPPORTED')
})

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env node
// Read-only NBD server serving a file through its standard streams, à la
// `nbdkit -s`. Used by nbdstdio.integ.mjs, since nbdkit is not necessarily
// installed on the test machine.
//
// Usage: stdio-nbd-server.mjs <file> [--export-name=NAME] [--die-after=N]
//
// --die-after=N exits with a failure after having answered N read requests, to
// exercise the reconnection path of the client.
import fs from 'node:fs/promises'
import { serveNbd } from './fake-nbd-server.mjs'
const args = process.argv.slice(2)
const path = args.find(arg => !arg.startsWith('--'))
if (path === undefined) {
console.error('usage: stdio-nbd-server.mjs <file> [--export-name=NAME] [--die-after=N]')
process.exit(64)
}
const getOption = (name, defaultValue) => {
const prefix = `--${name}=`
const arg = args.find(arg => arg.startsWith(prefix))
return arg === undefined ? defaultValue : arg.slice(prefix.length)
}
const exportName = getOption('export-name', '')
const dieAfter = Number(getOption('die-after', Infinity))
const data = await fs.readFile(path)
let answered = 0
try {
await serveNbd({
readable: process.stdin,
writable: process.stdout,
data,
exportName,
errorCode: () => {
if (++answered > dieAfter) {
console.error(`stdio-nbd-server: dying after ${dieAfter} read(s)`)
process.exit(1)
}
return 0
},
})
} catch (error) {
console.error('stdio-nbd-server:', error)
process.exit(1)
}

View File

@@ -1,6 +1,6 @@
{
"name": "@vates/node-vsphere-soap",
"version": "2.1.3",
"version": "2.2.0",
"description": "interface to vSphere SOAP/WSDL from node for interfacing with vCenter or ESXi, forked from node-vsphere-soap",
"main": "lib/client.mjs",
"author": "reedog117",

View File

@@ -1,7 +1,7 @@
{
"name": "@vates/types",
"private": false,
"version": "2.0.1",
"version": "2.5.0",
"main": "./dist/index.mjs",
"exports": {
".": {
@@ -34,7 +34,7 @@
"typescript": "~5.6.3",
"typescript-eslint": "^8.61.0",
"@types/express": "^5.0.0",
"@xen-orchestra/disk-transform": "^1.3.3"
"@xen-orchestra/disk-transform": "^1.3.4"
},
"scripts": {
"build": "tsc",

View File

@@ -663,6 +663,19 @@ export const SUPPORTED_VDI_FORMAT = {
export type SUPPORTED_VDI_FORMAT = (typeof SUPPORTED_VDI_FORMAT)[keyof typeof SUPPORTED_VDI_FORMAT]
/**
* Formats a VDI can be exported to by XO: the XAPI ones, plus the ones XO generates
* itself from the blocks of the disk.
*/
export const SUPPORTED_VDI_EXPORT_FORMAT = {
raw: 'raw',
vhd: 'vhd',
qcow2: 'qcow2',
vmdk: 'vmdk',
} as const
export type SUPPORTED_VDI_EXPORT_FORMAT = (typeof SUPPORTED_VDI_EXPORT_FORMAT)[keyof typeof SUPPORTED_VDI_EXPORT_FORMAT]
// ----- XAPI Stats
type XapiStatsResponse<T> = {
@@ -800,3 +813,7 @@ export const XAPI_TYPES: readonly string[] = [
export const RPU_RECOVERY_STEP_NAMES = ['evacuate', 'update', 'reboot', 'enable', 'restoreVms'] as const
export type RPU_RECOVERY_STEP_NAME = (typeof RPU_RECOVERY_STEP_NAMES)[number]
export const COMMON_TAG = {
NOBAK: 'xo:no-bak',
} as const

View File

@@ -4,5 +4,8 @@ export * from './xo.mjs'
export * from './xo-app.mjs'
export * from './lib/xen-orchestra-xapi.mjs'
export * from './lib/xen-orchestra-acl.mjs'
export * from './lib/xen-orchestra-plugins.mjs'
export * from './lib/xen-orchestra-v2v.mjs'
export * from './lib/complex-matcher.mjs'
export * from './lib/rest-api.mjs'
export * from './lib/xen-orchestra-vmware-explorer.mjs'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,219 @@
import type { Branded } from '../common.mjs'
import type { XoNetwork, XoSr, XoVmTemplate } from '../xo.mjs'
// Stateless API: requests needing the source carry the connection in a POST body (never in URLs or access logs) and
// xo-server stores nothing it reads from the source.
export const V2V_PLATFORM = {
VMWARE: 'vmware',
} as const
export type V2V_PLATFORM = (typeof V2V_PLATFORM)[keyof typeof V2V_PLATFORM]
// 422 rather than 401 for invalid source credentials: a 401 would open the browser Basic authentication prompt
export const V2V_ERROR_CODE = {
INVALID_CREDENTIALS: 'V2V_INVALID_CREDENTIALS',
} as const
export type V2V_ERROR_CODE = (typeof V2V_ERROR_CODE)[keyof typeof V2V_ERROR_CODE]
// Clones, restores and replicas inherit `other_config`: the imported VM records its own UUID to be told apart from
// them, and the source identity because a moref is only unique within one vCenter. The source identity is the
// inventory `instanceUuid`, or `<lowercased host>:<port>` for a standalone ESXi which has none.
export const V2V_VM_OTHER_CONFIG = {
SOURCE_VM_ID: 'sourceVmId',
SOURCE_SNAPSHOT_ID: 'sourceSnapshotId',
IMPORTED_VM_UUID: 'xo:v2v:importedVmUuid',
SOURCE_ID: 'xo:v2v:sourceId',
SOURCE_VM_INSTANCE_UUID: 'xo:v2v:sourceVmInstanceUuid',
} as const
export type V2V_VM_OTHER_CONFIG = (typeof V2V_VM_OTHER_CONFIG)[keyof typeof V2V_VM_OTHER_CONFIG]
export type XoV2vVmOtherConfig = Partial<Record<V2V_VM_OTHER_CONFIG, string>>
// The disk of the source chain an imported VDI last received, one of the `chainPaths` of its source disk: the CID
// cannot tell a fresh snapshot delta from its parent, the path can
export const V2V_VDI_OTHER_CONFIG = {
DISK_PATH: 'esxi_diskPath',
} as const
export type V2V_VDI_OTHER_CONFIG = (typeof V2V_VDI_OTHER_CONFIG)[keyof typeof V2V_VDI_OTHER_CONFIG]
export const V2V_START_BLOCKED_REASON = 'Esxi migration in progress...'
export const V2V_PARTIAL_IMPORT_TAG = 'xo:no-bak=V2V partial import'
export const V2V_SNAPSHOT_NAME = {
AFTER_PARTIAL_IMPORT: 'after partial import from V2V',
AFTER_COMPLETE_IMPORT: 'after complete import from V2V',
COMPLETE_IMPORT: 'complete import from V2V',
} as const
export type V2V_SNAPSHOT_NAME = (typeof V2V_SNAPSHOT_NAME)[keyof typeof V2V_SNAPSHOT_NAME]
// Set on the task of a start so the front can tell which source VMs are being imported
export const V2V_TASK_PROPERTY = {
SOURCE_VM_IDS: 'sourceVmIds',
} as const
export type XoV2vConnection = {
host: string
port: number
username: string
password: string
skipSslCheck: boolean
}
export type XoV2vStatus = {
vectura: { status: 'success' } | { status: 'error'; error: string }
}
export type XoV2vInventoryPayload = {
platform: V2V_PLATFORM
connection: XoV2vConnection
}
export type XoV2vDatacenter = {
id: Branded<'v2v-datacenter'>
name: string
}
export type XoV2vCluster = {
id: Branded<'v2v-cluster'>
name: string
datacenterId: XoV2vDatacenter['id']
}
export type XoV2vHost = {
id: Branded<'v2v-host'>
name: string
clusterId?: XoV2vCluster['id']
datacenterId: XoV2vDatacenter['id']
}
// Sizes are in bytes, as in the rest of XO, not in the KB/MB VMware returns
export type XoV2vDatastore = {
id: Branded<'v2v-datastore'>
name: string
type: string
capacity: number
free: number
hostIds: XoV2vHost['id'][]
}
export type XoV2vPortGroup = {
id: Branded<'v2v-port-group'>
name: string
vlan?: number
}
export const V2V_DISK_MODE = {
PERSISTENT: 'persistent',
NON_PERSISTENT: 'nonpersistent',
INDEPENDENT_PERSISTENT: 'independent_persistent',
INDEPENDENT_NON_PERSISTENT: 'independent_nonpersistent',
UNDOABLE: 'undoable',
APPEND: 'append',
} as const
export type V2V_DISK_MODE = (typeof V2V_DISK_MODE)[keyof typeof V2V_DISK_MODE]
// `key` is the vmx device node (`scsi0:0`, `ethernet0`…), the identifier the import matches the start maps against
export type XoV2vDisk = {
key: Branded<'v2v-disk'>
label: string
datastoreId: XoV2vDatastore['id']
capacity: number
mode: V2V_DISK_MODE
isRdm: boolean
isEncrypted: boolean
// `dir/disk.vmdk` in its datastore (no `[datastore]` prefix) of the base disk, then of each snapshot delta down to
// the active disk: the chain the import walks for this vmx node
chainPaths: string[]
}
// No port group for opaque (NSX) or missing backings
export type XoV2vNic = {
key: Branded<'v2v-nic'>
label: string
portGroupId?: XoV2vPortGroup['id']
macAddress: string
}
export const V2V_VM_POWER_STATE = {
POWERED_ON: 'poweredOn',
POWERED_OFF: 'poweredOff',
SUSPENDED: 'suspended',
} as const
export type V2V_VM_POWER_STATE = (typeof V2V_VM_POWER_STATE)[keyof typeof V2V_VM_POWER_STATE]
export const V2V_FIRMWARE = {
BIOS: 'bios',
UEFI: 'uefi',
} as const
export type V2V_FIRMWARE = (typeof V2V_FIRMWARE)[keyof typeof V2V_FIRMWARE]
export type XoV2vVm = {
id: Branded<'v2v-vm'>
instanceUuid: string
name: string
description: string
powerState: V2V_VM_POWER_STATE
hostId: XoV2vHost['id']
nCpus: number
memory: number
firmware: V2V_FIRMWARE
secureBoot: boolean
datastoreIds: XoV2vDatastore['id'][]
snapshotCount: number
// Compared with `other_config.sourceSnapshotId` of a partial import: a delta is only possible while they match
currentSnapshotId?: string
disks: XoV2vDisk[]
nics: XoV2vNic[]
}
export type XoV2vInventory = {
product: string
version: string
// Absent on a standalone ESXi, identified by its connection address instead
instanceUuid?: string
datacenters: XoV2vDatacenter[]
clusters: XoV2vCluster[]
hosts: XoV2vHost[]
datastores: XoV2vDatastore[]
portGroups: XoV2vPortGroup[]
vms: XoV2vVm[]
}
export type XoV2vStartOptions = {
stopSource: boolean
startVm: boolean
cleanup: boolean
}
// A resumed import sends no disk or NIC map: the back end reads its mapping from the existing XCP-ng VM
export type XoV2vStartPayloadVm = Partial<XoV2vStartOptions> & {
id: XoV2vVm['id']
disks?: Record<XoV2vDisk['key'], XoSr['id']>
nics?: Record<XoV2vNic['key'], XoNetwork['id']>
template?: XoVmTemplate['id']
tags: string[]
keepMac: boolean
}
// Body of `POST /pools/{id}/actions/import_vms_from_vmware`: the pool is in the path. Checked against the body of the
// route when the REST API is built, which refuses any extra key
export type XoV2vStartPayload = {
connection: XoV2vConnection
defaults: XoV2vStartOptions & {
sr: XoSr['id']
network: XoNetwork['id']
template: XoVmTemplate['id']
}
vms: XoV2vStartPayloadVm[]
stopOnError: boolean
concurrency: number
}

View File

@@ -0,0 +1,101 @@
// shape of `Esxi#getInventory()` in `@xen-orchestra/vmware-explorer`
// the objects reference each other through their VMware managed object references (moref)
export type VmwareDatacenter = {
id: string
name: string
}
export type VmwareCluster = {
id: string
name: string
datacenter?: string
}
export type VmwareHost = {
id: string
name: string
/** a cluster, or the compute resource of a standalone host */
parent?: string
datacenter?: string
}
export type VmwareDatastore = {
id: string
name?: string
type?: string
capacity?: number
freeSpace?: number
/** the hosts which mount the datastore and can reach it */
hosts: string[]
}
export type VmwareVlan = number | { start: number; end: number }[]
export type VmwareStandardNetwork = {
id: string
name: string
distributed: false
}
export type VmwareDistributedPortgroup = {
id: string
name: string
distributed: true
vlan?: VmwareVlan
}
export type VmwareNetwork = VmwareStandardNetwork | VmwareDistributedPortgroup
export type VmwareVmDisk = {
/** node of the disk, as the vmx names it, e.g. `scsi0:0` */
key: string
label: string
capacity: number
datastore?: string
diskMode?: string
rdm: boolean
encrypted: boolean
}
export type VmwareVmNic = {
/** device key of the card */
key: string
label: string
macAddress?: string
addressType?: string
network?: string
}
export type VmwareVm = {
id: string
name?: string
annotation?: string
instanceUuid?: string
firmware?: string
secureBoot: boolean
isTemplate: boolean
cpus?: number
memory?: number
powerState?: 'poweredOff' | 'poweredOn' | 'suspended'
host?: string
datastores: string[]
currentSnapshot?: string
snapshotCount: number
disks: VmwareVmDisk[]
nics: VmwareVmNic[]
}
export type VmwareInventory = {
/** e.g. `VMware vCenter Server`, `VMware ESXi` */
product?: string
version?: string
/** only set by a vCenter */
instanceUuid?: string
datacenters: VmwareDatacenter[]
clusters: VmwareCluster[]
hosts: VmwareHost[]
datastores: VmwareDatastore[]
networks: VmwareNetwork[]
vms: VmwareVm[]
}

View File

@@ -25,6 +25,7 @@ import type {
VM_OPERATIONS,
} from '../common.mjs'
import type { DiskPassthrough } from '@xen-orchestra/disk-transform'
import type { EventEmitter } from 'node:events'
import type { PassThrough, Readable } from 'node:stream'
import type {
XapiXoRecord,
@@ -129,6 +130,8 @@ export type XsPatches = {
uuid?: string
}
type WrappedRecordOfType<Type extends WrappedXenApiRecord['$type']> = Extract<WrappedXenApiRecord, { $type: Type }>
export interface Xapi {
status: string
pool?: { uuid: string }
@@ -146,6 +149,14 @@ export interface Xapi {
ref: T['$ref'],
field: K
): Promise<T[K]>
// the record is inferred from `type`, for a JS caller which cannot spell it out as a type argument
getField<Type extends WrappedXenApiRecord['$type'], Field extends keyof WrappedRecordOfType<Type>>(
type: Type,
ref: WrappedRecordOfType<Type>['$ref'],
field: Field
): Promise<WrappedRecordOfType<Type>[Field]>
/** Synchronous: the record as the object cache currently holds it. */
getObjectByRef<Ref extends WrappedXenApiRecord['$ref']>(ref: Ref): Extract<WrappedXenApiRecord, { $ref: Ref }>
getObject: <
XoRecord extends XapiXoRecord,
WrappedRecord extends WrappedXenApiRecord = Extract<WrappedXenApiRecord, { $type: XoRecord['type'] }>,
@@ -282,6 +293,42 @@ export interface Xapi {
}
): Promise<XenApiVdi['$ref']>
SR_reclaimSpace(ref: XenApiSr['$ref']): Promise<void>
VBD_create(params: {
bootable?: boolean
currently_attached?: boolean
device?: string
empty?: boolean
mode?: VBD_MODE
other_config?: Record<string, string>
qos_algorithm_params?: Record<string, string>
qos_algorithm_type?: string
/** a plug failure, only logged otherwise, is thrown: the VBD exists all the same */
throwVbdPlug?: boolean
type?: VBD_TYPE
unpluggable?: boolean
userdevice?: string
VDI?: XenApiVdi['$ref']
VM: XenApiVm['$ref']
}): Promise<XenApiVbd['$ref']>
/** unplugs the VBD first, if needed */
VBD_destroy(ref: XenApiVbd['$ref']): Promise<void>
VDI_create(
params: {
name_description?: string
name_label: string
other_config?: Record<string, string>
read_only?: boolean
sharable?: boolean
SR?: XenApiSr['$ref']
tags?: string[]
type?: VDI_TYPE
virtual_size: number
xenstore_data?: Record<string, string>
},
opts?: { sm_config?: Record<string, string> }
): Promise<XenApiVdi['$ref']>
/** a VDI which no longer exists counts as destroyed */
VDI_destroy(ref: XenApiVdi['$ref']): Promise<void>
editVm(
id: XoVm['id'],
props: EditVmProps,
@@ -470,6 +517,12 @@ export interface Xapi {
destroySr(id: XoSr['id']): Promise<void>
xostor_delete(ref: XenApiSr['$ref']): Promise<void>
objects: {
// emits `add`/`update`/`remove` only while the connection watches the pool events
allIndexes: {
type: {
getEventEmitterByType(type: WrappedXenApiRecord['$type']): EventEmitter
}
}
indexes: {
type: {
[XenApiRecord in WrappedXenApiRecord as XenApiRecord['$type']]: Record<XenApiRecord['uuid'], XenApiRecord>

View File

@@ -824,7 +824,10 @@ export interface XenApiSr {
VDIs: XenApiVdi['$ref'][]
virtual_allocation: number
}
export type XenApiSrWrapped = WrapperXenApi<XenApiSr, 'SR', XenApiSrCallMethods>
export type XenApiSrWrapped = WrapperXenApi<XenApiSr, 'SR', XenApiSrCallMethods> & {
// resolved by xen-api from `PBDs`
$PBDs: XenApiPbdWrapped[]
}
export interface XenApiSrStat {
$ref: Branded<'sr_stat'>

View File

@@ -1,26 +1,33 @@
import { EventEmitter } from 'node:stream'
import type { EventEmitter, Readable } from 'node:stream'
import type {
AnyXoJob,
AnyXoLog,
NonXapiXoRecord,
XapiXoRecord,
XoAuthenticationToken,
XoBackupRepository,
XoConfigBackupArchive,
XoGroup,
XoHost,
XoNetwork,
XoPlugin,
XoPool,
XoPoolBackupArchive,
XoProxy,
XoSchedule,
XoServer,
XoSr,
XoTask,
XoUser,
XoVif,
XoVm,
XoVmBackupArchive,
XoVmTemplate,
} from './xo.mjs'
import { VatesTask } from './lib/vates-task.mjs'
import type { PluginRestRouteDefinition } from './lib/rest-api.mjs'
import type { VmwareInventory } from './lib/xen-orchestra-vmware-explorer.mjs'
import type { XoV2vStatus } from './lib/xen-orchestra-v2v.mjs'
import type { RPU_RECOVERY_STEP_NAME } from './common.mjs'
import {
Xapi,
@@ -150,13 +157,13 @@ export type PoolRollingUpdateRecoveryHost = {
export type PoolRollingUpdateRecoveryRun = {
runId: string
poolId: string
status: 'preparing' | 'running' | 'interrupted' | 'resuming' | 'failed' | 'cleaning'
status: 'preparing' | 'running' | 'interrupted' | 'resuming' | 'failed' | 'cleaning' | 'succeeded'
startedAt: string
updatedAt: string
finishedAt?: string
interruptedAt?: string
taskId?: string
variant?: 'xcp' | 'xs-cdn' | 'xs-legacy'
variant?: 'xcp' | 'xs-cdn'
hostOrder?: string[]
hosts: Record<string, PoolRollingUpdateRecoveryHost>
lastError: PoolRollingUpdateRecoveryError | null
@@ -174,7 +181,41 @@ export type PoolRollingUpdateRecoveryBlocked = {
export type PoolRollingUpdateRecovery = PoolRollingUpdateRecoveryRun | PoolRollingUpdateRecoveryBlocked
export type XoApp = {
/** A disk of a backup archive currently served as an iSCSI LUN */
export type BackupArchiveDiskMount = {
/** Handle to pass to `unmountBackupArchiveDisk` */
id: string
/** UUID of the SR introduced on the host */
srUuid: string
/** UUID of the VDI exposing the backup disk, read-only unless it is cached */
vdiUuid: string
/** IQN of the target serving the disk */
iqn: string
/** Address of the portal, as advertised to the host */
address: string
/** Port of the portal (ephemeral, one target per mount) */
port: number
}
type XoStoreRangeOptions = {
gt?: string
gte?: string
lt?: string
lte?: string
limit?: number
reverse?: boolean
}
// `@xen-orchestra/vmware-explorer` is not typed
export type EsxiConnectionParams = {
/** `host` or `host:port` */
host: string
user: string
password: string
sslVerify: boolean
}
export type XoApp = EventEmitter & {
hooks: EventEmitter
_redis: {
get(key: string): Promise<string | null>
@@ -229,6 +270,7 @@ export type XoApp = {
user?: XoUser
permission?: XoUser['permission'] | 'none' | null
}
backupLogsEe: EventEmitter
// methods ------------
allocIpAddresses(vifId: XoVif['id'], addAddresses?: string[], removeAddresses?: string[]): Promise<void>
@@ -253,6 +295,8 @@ export type XoApp = {
backupGuard(objectId: XapiXoRecord['id'], opts?: { bypassBackupCheck?: boolean; operation: string }): Promise<void>
/* Throw if no authorization */
checkFeatureAuthorization(featureCode: FeatureCode): Promise<void>
/* whether this machine can run the tools of an import from VMware */
checkEsxiDependencies(): Promise<XoV2vStatus>
/* validate, apply and persist the configuration of a plugin */
configurePlugin(id: string, configuration: unknown, mergeWithExisting?: boolean): Promise<void>
/* connect a server (XCP-ng/XenServer) */
@@ -268,7 +312,18 @@ export type XoApp = {
id: XoAclRole['id'],
params?: { name?: XoAclRole['name']; description?: XoAclRole['description'] }
): Promise<XoAclRole['id']>
createAclV2Role(role: { name: XoAclRole['name']; description?: XoAclRole['description'] }): Promise<XoAclRole>
createAclV2Role(role: {
name: XoAclRole['name']
description?: XoAclRole['description']
privileges?: Omit<XoAclBasePrivilege, 'id' | 'roleId'>[]
}): Promise<XoAclRole>
createApiConnection(remoteAddress: string): EventEmitter & {
close(): void
get<T = unknown>(key: string, defaultValue: unknown): T
has(key: string): boolean
set(key: string, value: unknown): void
unset(key: string): void
}
createAuthenticationToken(opts: {
client?: {
id?: string
@@ -284,7 +339,14 @@ export type XoApp = {
proxy?: XoProxy['id']
url: string
}): Promise<XoBackupRepository>
createUser(params: { name?: string; password?: string; [key: string]: unknown }): Promise<XoUser>
createUser(params: {
firstname?: string
lastname?: string
name?: string
password?: string
username?: string
[key: string]: unknown
}): Promise<XoUser>
deleteAclV2GroupRole(
groupId: XoGroup['id'],
roleId: XoAclRole['id'],
@@ -296,8 +358,10 @@ export type XoApp = {
deleteUser(id: XoUser['id']): Promise<void>
detachHostFromPool(hostId: XoHost['id']): Promise<void>
createGroup(params: { name: string; provider?: string; providerGroup?: string }): Promise<XoGroup>
disablePluginAutoload(id: XoPlugin['id']): Promise<void>
/* disconnect a server (XCP-ng/XenServer) */
disconnectXenServer(id: XoServer['id']): Promise<void>
enablePluginAutoload(id: XoPlugin['id']): Promise<void>
findEnabledScheduleSequenceFromSchedule(id: XoSchedule['id']): Promise<XoSchedule | undefined>
getAclV2Privilege(id: XoAclBasePrivilege['id']): Promise<XoAclBasePrivilege>
getAclV2Privileges(): Promise<XoAclBasePrivilege[]>
@@ -308,6 +372,7 @@ export type XoApp = {
roleId: XoAclRole['id'],
opts?: { bypassAuthorization?: boolean }
): Promise<boolean>
deleteSchedule(id: XoSchedule['id']): Promise<void>
getAclV2GroupRoles(
groupId: XoGroup['id'],
opts?: { bypassAuthorization?: boolean }
@@ -322,6 +387,8 @@ export type XoApp = {
getAllProxies(): Promise<XoProxy[]>
getAllJobs<T extends AnyXoJob['type']>(type: T): Promise<Extract<AnyXoJob, { type: T }>[]>
getAllJobs(type?: string): Promise<AnyXoJob[]>
getPlugin(id: XoPlugin['id']): Promise<XoPlugin>
getPlugins(): Promise<XoPlugin[]>
getProxy(id: XoProxy['id']): Promise<XoProxy>
getRemote(id: XoBackupRepository['id']): Promise<XoBackupRepository>
getAllRemotes(): Promise<XoBackupRepository[]>
@@ -345,8 +412,19 @@ export type XoApp = {
getAllUsers(opts?: { obfuscatePassword?: boolean }): Promise<XoUser[]>
getAllXenServers(): Promise<XoServer[]>
getAuthenticationTokensForUser(userId: XoUser['id']): Promise<XoAuthenticationToken[]>
/** Archive/host a live-mounted disk belongs to, as recorded by `mountBackupArchiveDisk` */
getBackupArchiveDiskMountOwner(id: BackupArchiveDiskMount['id']): {
archiveId: XoVmBackupArchive['id']
hostId: XoHost['id']
/** set when the disk is served by a proxy instead of this appliance */
proxyId?: XoProxy['id']
}
getBackupNgLogs(): Promise<Record<string, AnyXoLog>>
getBackupNgLogs(id: AnyXoLog['id']): Promise<AnyXoLog>
/** Please use `REMOVE_CACHE_ENTRY` const to clear the cache */
getBackupNgLogs(removeCacheEntry: {}): void
/** Please use `REMOVE_CACHE_ENTRY` const to clear a cache entry */
getBackupNgLogs(removeCacheEntry: {}, id: AnyXoLog['id']): void
getBackupNgLogsSorted(opts: {
after?: number
before?: number
@@ -356,6 +434,7 @@ export type XoApp = {
getGroup(id: XoGroup['id']): Promise<XoGroup>
getHVSupportedVersions: undefined | (() => Promise<{ [key: XoHost['productBrand']]: string }>)
getJob<T extends AnyXoJob>(id: T['id']): Promise<T>
isJobSequence(job: AnyXoJob): boolean
getObject: <T extends XapiXoRecord>(id: T['id'], type?: T['type'] | T['type'][]) => T
getObjectsByType: <T extends XapiXoRecord>(
type: T['type'],
@@ -363,7 +442,21 @@ export type XoApp = {
) => Record<T['id'], T> | undefined
getTotalBackupSizeOnRemote(id: XoBackupRepository['id']): Promise<{ onDisk: number }>
getSchedule(id: XoSchedule['id']): Promise<XoSchedule>
getStore(namespace: string): Promise<{
/** rejects with an error having `notFound: true` when the key does not exist */
get(key: string): Promise<unknown>
has(key: string): Promise<boolean>
put(key: string, value: unknown): Promise<void>
del(key: string): Promise<void>
del(key: string, cb: (error?: Error | null) => void): void
/** object mode stream of `{ key, value }` */
createReadStream(opts?: XoStoreRangeOptions): Readable
/** object mode stream of keys */
createKeyStream(opts?: XoStoreRangeOptions): Readable
}>
getUser: (id: XoUser['id'], opts?: { obfuscatePassword?: boolean }) => Promise<XoUser>
getUserIdentityFields(): string[]
getEsxiInventory(params: EsxiConnectionParams): Promise<VmwareInventory>
getXapi(maybeId: XapiXoRecord['id'] | XapiXoRecord): Xapi
getXapiHostStats: (hostId: XoHost['id'], granularity?: XapiStatsGranularity) => Promise<XapiHostStats>
getXapiObject: <T extends XapiXoRecord>(
@@ -373,9 +466,13 @@ export type XoApp = {
getXapiPoolStats(poolId: XoPool['id'], granularity?: XapiStatsGranularity): Promise<XapiPoolStats>
getXapiVmStats: (vmId: XoVm['id'], granularity?: XapiStatsGranularity) => Promise<XapiVmStats>
getXenServer(id: XoServer['id']): Promise<XoServer>
/** the connection of the pool holding this object, which may not be connected */
getXenServerIdByObject<T extends XapiXoRecord>(maybeId: T['id'] | T, type?: T['type'] | T['type'][]): XoServer['id']
getXenServerWithCredentials(id: XoServer['id']): Promise<XoServer & { password: string }>
getXoEventEmitterByType(type: string): EventEmitter
hasFeatureAuthorization(featureCode: string): Promise<boolean>
hasObject<T extends XapiXoRecord>(id: T['id'], type: T['type']): boolean
loadPlugin(id: XoPlugin['id']): Promise<void>
listMetadataBackups(backupRepositoryIds: XoBackupRepository['id'][]): Promise<{
xo: Record<XoBackupRepository['id'], XoConfigBackupArchive[]>
pool: Record<XoBackupRepository['id'], Record<XoPool['id'], XoPoolBackupArchive[]>>
@@ -385,7 +482,52 @@ export type XoApp = {
backupRepositoryIds: XoBackupRepository['id'][],
opts?: { _forceRefresh?: boolean; vmId?: XoVm['id'] }
): Promise<Record<XoBackupRepository['id'], Record<XoVm['id'], XoVmBackupArchive[]> | null>>
/** imports one VM from VMware, resuming a previous partial import when possible, returns the UUID of the XCP-ng VM */
migrationfromEsxi(
params: EsxiConnectionParams & {
sr: XoSr['id']
network: XoNetwork['id']
/** overrides `sr`, by disk node (e.g. `scsi0:0`) */
disks?: Record<string, XoSr['id']>
/** overrides `network`, by NIC device key (e.g. `4000`) */
nics?: Record<string, XoNetwork['id']>
template: XoVmTemplate['id']
stopSource: boolean
/** VMware moref of the VM */
vm: string
}
): Promise<XoVm['uuid']>
/**
* Serve one disk of a backup archive as an iSCSI LUN and attach it to `host`
* as an SR, read-only unless `cacheSrId` is set. Undone by `unmountBackupArchiveDisk`.
*
* The LUN is served by whoever can read the backup repository: this appliance, or the proxy the
* repository is linked to.
*/
mountBackupArchiveDisk(params: {
archiveId: XoVmBackupArchive['id']
/**
* SR of a VDI of the serving appliance, which the disk is materialized into as it is read and
* which holds the writes; unset, nothing is cached and the mount is read-only. On a local SR,
* the serving appliance must not be migrated while the mount lasts: the VDI cannot follow it
*/
cacheSrId?: XoSr['id']
/** One of the archive's `disks[].id` */
diskId: string
hostId: XoHost['id']
}): Promise<BackupArchiveDiskMount>
purgePluginConfiguration(id: XoPlugin['id']): Promise<void>
pingRemote(id: XoBackupRepository['id']): Promise<{ success: true }>
/**
* Record the live mounts a proxy created by itself, while running a restore: they never went
* through `mountBackupArchiveDisk`, so nothing else knows which proxy serves them.
*/
registerProxyBackupArchiveDiskMounts(params: {
archiveId: XoVmBackupArchive['id']
/** as reported by the restore, each with the host it is attached to */
mounts: { id: BackupArchiveDiskMount['id']; hostId: XoHost['id'] }[]
proxyId: XoProxy['id']
}): void
/** Allow to add a new server in the DB (XCP-ng/XenServer) */
registerXenServer(
body: Pick<XoServer, 'host' | 'httpProxy' | 'label' | 'username'> & {
@@ -400,34 +542,53 @@ export type XoApp = {
): Promise<void>
rollingPoolUpdate(
pool: XoPool,
opts?: { bypassBackupCheck?: boolean; rebootVm?: boolean; parentTask?: VatesTask; shutdownPinnedVms?: boolean }
opts?: {
acceptCurrentStateAsBaseline?: boolean
bypassBackupCheck?: boolean
rebootVm?: boolean
parentTask?: VatesTask
shutdownPinnedVms?: boolean
}
): Promise<void>
getRollingUpdateRecovery(poolId: XoPool['id']): Promise<PoolRollingUpdateRecovery | undefined>
finalizeRollingUpdate(pool: XoPool, opts?: { force?: boolean; parentTask?: VatesTask }): Promise<void>
setVmResourceSet(vmId: XoVm['id'], resourceSetId: string | null, force?: boolean): Promise<void>
shareVmResourceSet(vmId: XoVm['id']): Promise<void>
removeUserFromGroup(userId: XoUser['id'], id: XoGroup['id']): Promise<void>
runJob(job: AnyXoJob, schedule: XoSchedule): void
runWithApiContext: (user: XoUser | undefined, fn: () => void) => Promise<unknown>
testPlugin(id: XoPlugin['id'], data?: unknown): Promise<void>
testRemote(
id: XoBackupRepository['id']
): Promise<
| { success: true; readRate: number; writeRate: number }
| { success: false; step: string; file: string; error: unknown }
>
touchXoObject(type: string, id: NonXapiXoRecord['id']): Promise<void>
/** Detach a disk mounted by `mountBackupArchiveDisk` and stop serving it */
unmountBackupArchiveDisk(id: BackupArchiveDiskMount['id']): Promise<void>
unloadPlugin(id: XoPlugin['id']): Promise<void>
reclaimSpace(
remoteId: XoBackupRepository['id'],
opts?: { vmUuid?: XoVm['id']; merge?: boolean; remove?: boolean }
): Promise<{ vmUuid: string; success: boolean; merge?: boolean; size?: number; error?: string }[]>
/** Remove a server from the DB (XCP-ng/XenServer) */
unregisterXenServer(id: XoServer['id']): Promise<void>
updateUser(
id: XoUser['id'],
updates: {
email?: string
authProviders?: Record<string, string>
firstname?: string
lastname?: string
/**
* @deprecated
*/
email?: string
authProviders?: Record<string, string>
name?: string
password?: string
permission?: string
preferences?: Record<string, string>
username?: string
}
): Promise<void>
updateAclV2Privilege(

View File

@@ -21,6 +21,29 @@ import type {
import type * as CMType from './lib/complex-matcher.mjs'
import { XoAclBasePrivilege, XoAclRole } from './lib/xen-orchestra-acl.mjs'
import type { XenApiHost, XenApiPool } from './xen-api.mjs'
import type {
XoAuditPlugin,
XoAuthGithubPlugin,
XoAuthGooglePlugin,
XoAuthLdapPlugin,
XoAuthOidcPlugin,
XoAuthSamlPlugin,
XoBackupReportsPlugin,
XoIpmiSensorsPlugin,
XoLoadBalancerPlugin,
XoNetboxPlugin,
XoOpenmetricsPlugin,
XoPerfAlertPlugin,
XoSdnControllerPlugin,
XoTestPluginPlugin,
XoTransportEmailPlugin,
XoTransportIcinga2Plugin,
XoTransportNagiosPlugin,
XoTransportSlackPlugin,
XoTransportXmppPlugin,
XoUsageReportPlugin,
XoWebHooksPlugin,
} from './lib/xen-orchestra-plugins.mjs'
type BaseXapiXo = {
$pool: XoPool['id']
@@ -118,7 +141,7 @@ type BaseXoVm = BaseXapiXo & {
/**
* @deprecated use pvDriversVersion instead
*/
xentools?:
xenTools?:
| false
| {
major: null | number
@@ -201,15 +224,24 @@ export type XoPoolBackupArchive = XoMetadataBackupArchive & {
type: 'xo-pool-metadata-backup'
}
export type XoBackupRepositoryBenchmark = {
readRate: number
timestamp: number
writeRate: number
}
export type XoBackupRepository = {
benchmarks?: { readRate: number; timestamp: number; writeRate: number }[]
available?: number
benchmarks?: XoBackupRepositoryBenchmark[]
enabled: boolean
error?: Record<string, unknown>
id: Branded<'backup-repository'>
name: string
options?: string
proxy?: XoProxy['id']
size?: number
url: string
used?: number
}
export type XoGpuGroup = BaseXapiXo & {
@@ -451,6 +483,29 @@ export type XoPool = BaseXapiXo & {
zstdSupported: boolean
}
export type XoPlugin =
| XoAuditPlugin
| XoAuthGithubPlugin
| XoAuthGooglePlugin
| XoAuthLdapPlugin
| XoAuthOidcPlugin
| XoAuthSamlPlugin
| XoBackupReportsPlugin
| XoIpmiSensorsPlugin
| XoLoadBalancerPlugin
| XoNetboxPlugin
| XoOpenmetricsPlugin
| XoPerfAlertPlugin
| XoSdnControllerPlugin
| XoTestPluginPlugin
| XoTransportEmailPlugin
| XoTransportIcinga2Plugin
| XoTransportNagiosPlugin
| XoTransportSlackPlugin
| XoTransportXmppPlugin
| XoUsageReportPlugin
| XoWebHooksPlugin
export type XoProxy = {
id: Branded<'proxy'>
url: string
@@ -579,6 +634,9 @@ export type XoJob = BaseXoJob & {
[key: string]: unknown
}[]
}
userId?: XoUser['id']
createdBy?: XoUser['id']
updatedBy?: XoUser['id']
}
export type XoSchedule = {
@@ -600,6 +658,7 @@ export type XoServer = {
label?: string
master?: XoHost['id']
poolId?: XoPool['id']
poolMembersAddresses?: string[]
poolNameDescription?: string
poolNameLabel?: string
readOnly: boolean
@@ -619,6 +678,7 @@ export type XoSr = BaseXapiXo & {
current_operations: Record<string, STORAGE_OPERATIONS>
id: Branded<'SR'>
inMaintenanceMode: boolean
is_tools_sr: boolean
name_description: string
name_label: string
other_config: Record<string, string>
@@ -687,6 +747,7 @@ export type XoTask = {
| 'backup-log'
| 'backup-repository'
| 'group'
| 'plugin'
| 'proxy'
| 'restore'
| 'restore-log'
@@ -713,12 +774,18 @@ export type XoTask = {
export type XoUser = {
authProviders?: Record<string, string>
email: string
firstname?: string
groups: XoGroup['id'][]
id: Branded<'user'>
lastname?: string
/**
* @deprecated use username instead
*/
name?: string
permission: 'none' | 'admin'
pw_hash?: string
preferences: Record<string, string>
username?: string
}
export type XoAuthenticationToken = {
@@ -894,7 +961,9 @@ export type NonXapiXoRecord =
| AnyXoBackupArchive
| AnyXoJob
| AnyXoLog
| XoAuthenticationToken
| XoGroup
| XoPlugin
| XoProxy
| XoJob
| XoBackupRepository

View File

@@ -6,7 +6,7 @@
"main": "dist/index.mjs",
"name": "@xen-orchestra/acl",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/acl",
"version": "1.6.1",
"version": "1.8.0",
"license": "AGPL-3.0-or-later",
"private": false,
"type": "module",
@@ -30,7 +30,7 @@
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@vates/types": "^2.0.1",
"@vates/types": "^2.5.0",
"rimraf": "^6.0.1",
"typescript": "~5.6",
"typescript-eslint": "^8.61.0"

View File

@@ -10,4 +10,5 @@ export default {
proxy: true,
url: true,
},
'reclaim-space': true,
}

View File

@@ -25,4 +25,14 @@ export default {
update: {
tags: true,
},
probe: {
nfs: true,
zfs: true,
hba: true,
iscsiiqn: true,
iscsilun: true,
'iscsi-exists': true,
'hba-exists': true,
'nfs-exists': true,
},
}

View File

@@ -14,6 +14,7 @@ import { default as pbd } from './pbd.mjs'
import { default as pci } from './pci.mjs'
import { default as pgpu } from './pgpu.mjs'
import { default as pif } from './pif.mjs'
import { default as plugin } from './plugin.mjs'
import { default as pool } from './pool.mjs'
import { default as proxy } from './proxy.mjs'
import { default as restoreLog } from './restore-log.mjs'
@@ -56,6 +57,7 @@ export const SUPPORTED_ACTIONS_BY_RESOURCE = {
pci,
pgpu,
pif,
plugin,
pool,
proxy,
'restore-log': restoreLog,

View File

@@ -0,0 +1,10 @@
export default {
load: true,
read: true,
test: true,
unload: true,
update: {
autoload: true,
configuration: true,
},
}

View File

@@ -3,9 +3,12 @@ export default {
delete: true,
read: true,
update: {
firstname: true,
lastname: true,
name: true,
password: true,
permission: true,
preferences: true,
username: true,
},
}

View File

@@ -1,4 +1,3 @@
import assert from 'node:assert'
import type { XoUser } from '@vates/types/xo'
import { SUPPORTED_ACTIONS_BY_RESOURCE } from './actions/index.mjs'
@@ -42,10 +41,21 @@ export function hasPrivilegeOn<T extends SupportedResource>({
}) {
// Function that will be called outside of the module
// We cannot be sure types are respected
assert.strictEqual(typeof user?.permission, 'string')
assert.strictEqual(typeof action, 'string')
assert.strictEqual(typeof resource, 'string')
assert.strictEqual(objects === undefined, false)
if (typeof user?.permission !== 'string') {
throw new TypeError('user.permission must be a string')
}
if (typeof action !== 'string') {
throw new TypeError('action must be a string')
}
if (typeof resource !== 'string') {
throw new TypeError('resource must be a string')
}
if (objects === undefined) {
throw new TypeError('objects must be defined')
}
CPrivilege.checkActionIsValid(resource, action as SupportedActions<typeof resource>)

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/async-map",
"version": "0.1.4",
"version": "0.1.5",
"license": "ISC",
"description": "Promise.all + map for all iterables",
"keywords": [

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/backup-archive",
"version": "1.1.2",
"version": "1.2.0",
"description": "Helper to handle generic backup archives",
"license": "ISC",
"author": "",
@@ -21,25 +21,25 @@
"tar-stream": "^3.1.8",
"@types/tar-stream": "^3.1.4",
"tmp": "^0.2.1",
"@vates/types": "^2.0.1"
"@vates/types": "^2.5.0"
},
"dependencies": {
"@xen-orchestra/log": "^0.7.1",
"@xen-orchestra/fs": "^4.9.3",
"@xen-orchestra/disk-transform": "^1.3.3",
"@xen-orchestra/fs": "^4.10.0",
"@xen-orchestra/disk-transform": "^1.3.4",
"@vates/task": "0.7.0",
"golike-defer": "0.5.1",
"uuid": "^9.0.0",
"promise-toolbox": "^0.21.0",
"@vates/async-each": "^1.0.1",
"vhd-lib": "^4.16.1"
"vhd-lib": "^4.17.0"
},
"scripts": {
"build": "yarn run clean && tsc",
"dev": "tsc --watch",
"postversion": "npm publish --access public",
"test": "yarn run clean && tsc && node --test **/*.test.mjs",
"test-integration": "tsc && node --test **/*.integ.mjs",
"test": "yarn run clean && tsc && node --test './dist/**/*.test.mjs'",
"test-integration": "tsc && node --test './src/**/*.integ.mjs'",
"clean": "rimraf dist/"
},
"private": false,

View File

@@ -42,12 +42,13 @@ export class RemoteDiskLineage {
#childOf: Map<string, string> = new Map()
// Disk paths declared active by their owning archives (accumulated across all referencing archives)
#activeDiskPaths: Set<string> = new Set()
// Interrupted merges: normalized parent path { stateFilePath, chain }
#interruptedMerges: Map<string, { stateFilePath: string; chain?: string[] }> = new Map()
// Interrupted merges: normalized parent path { stateFilePath, chain, step }
#interruptedMerges: Map<string, { stateFilePath: string; chain?: string[]; step?: 'mergeBlocks' | 'cleanup' }> =
new Map()
constructor(handler: RemoteHandlerAbstract, vdiDir: string, opts: ResolvedBackupCleanOptions) {
this.#handler = handler
this.#vdiDir = vdiDir
this.#vdiDir = normalize(vdiDir)
this.#opts = opts
}
@@ -266,8 +267,38 @@ export class RemoteDiskLineage {
return undefined
}
// Read-only walk toward the descendants: true if any of them is still referenced by a backup.
// Unlike getUsedChildChainOrDelete, it neither marks disks visited nor schedules deletions.
const hasUsedDescendant = (diskPath: string): boolean => {
// `seen` guards against a cycle in corrupted headers
const seen = new Set<string>()
for (let p: string | undefined = diskPath; p !== undefined && !seen.has(p); p = this.#childOf.get(p)) {
seen.add(p)
if (!orphanDisks.has(p)) {
return true
}
}
return false
}
// Keeps the orphan loop away from a chain that must not be touched this run. Pinned disks are
// differencing: every ancestor they read through must survive too, however deep the lineage is
// (e.g. after a retention change). The ancestors are merged normally once the chain is
// resumed or dropped.
const pin = (paths: readonly string[]): void => {
for (const path of paths) {
// `seen` guards against a cycle in corrupted headers
const seen = new Set<string>()
for (let p: string | undefined = path; p !== undefined && !seen.has(p); p = this.#parentOf.get(p)) {
seen.add(p)
visited.add(p)
toDelete.delete(p)
}
}
}
// Process interrupted merges first so their disks are protected from the orphan loop
for (const [parentPath, { stateFilePath, chain: stateChain }] of this.#interruptedMerges) {
for (const [parentPath, { stateFilePath, chain: stateChain, step }] of this.#interruptedMerges) {
if (!this.#diskPaths.has(parentPath)) {
this.#opts.logWarn('orphan merge state', { stateFilePath, missingDisk: parentPath })
if (remove) {
@@ -279,14 +310,85 @@ export class RemoteDiskLineage {
let chain: string[] | undefined
if (stateChain !== undefined) {
const missing = stateChain.filter(p => !this.#diskPaths.has(p))
// A merge only removes disks during its cleanup step, so outside of it every disk of the
// recorded chain must still be there. Merging what is left would fold the surviving
// children into the parent and then rename it onto the chain tip, silently dropping the
// blocks of the disks that went missing. Never resume such a chain.
if (missing.length > 0 && step !== 'cleanup') {
// Each surviving disk is walked on its own: the missing disk breaks the `childOf` links,
// so a walk from the parent alone would stop at the gap.
const survivors = stateChain.filter(p => this.#diskPaths.has(p))
if (survivors.some(hasUsedDescendant)) {
// A backup still references a disk downstream: pin the chain so the orphan loop
// neither merges it afresh nor deletes it. It is dropped once nothing uses it anymore.
this.#opts.logWarn('merge chain lost disks before its cleanup step, refusing to resume', {
stateFilePath,
parentPath,
step,
missing,
})
pin(survivors)
continue
}
// Nothing uses the chain anymore (descendants of the missing disk are broken, so never
// active): drop the state, the orphan loop below deletes the disks.
this.#opts.logWarn('unmergeable merge chain on a fully orphaned lineage', {
stateFilePath,
parentPath,
step,
missing,
})
if (remove) {
this.#opts.logInfo('deleting merge state of orphaned lineage', { stateFilePath })
await this.#handler.unlink(stateFilePath)
}
continue
}
const existing = stateChain.filter(p => this.#diskPaths.has(p))
if (existing.length >= 2) chain = existing
if (existing.length >= 2) {
chain = existing
} else {
// fewer than 2 disks of the recorded chain are left, there is nothing to resume and
// the state would otherwise stay on disk forever.
//
// only done when the chain was read successfully: an undefined `stateChain` also
// covers a state file that is merely unreadable right now, which must be kept
this.#opts.logWarn('merge state without a resumable chain', { stateFilePath, parentPath })
if (remove) {
this.#opts.logInfo('deleting unresumable merge state', { stateFilePath })
await this.#handler.unlink(stateFilePath)
}
continue
}
}
if (chain !== undefined) {
chain.forEach(p => visited.add(p))
toMerge.push({ chain, isResuming: true })
if (chain === undefined) {
// state file unreadable: leave it alone, the orphan loop below schedules a fresh merge
continue
}
// Nothing downstream of the merge parent is still referenced by a backup: merging would
// write into a lineage nothing points at, and pinning it below would exempt it from the
// orphan loop forever. The walk already marked the whole lineage for deletion, so only
// the state file is left to drop.
//
// It walks the live lineage rather than `stateChain`, which is frozen at the retention
// boundary of the run that created it and can be shorter than the real chain.
if (getUsedChildChainOrDelete(parentPath) === undefined) {
this.#opts.logWarn('merge state on a fully orphaned lineage', { stateFilePath, parentPath })
if (remove) {
this.#opts.logInfo('deleting merge state of orphaned lineage', { stateFilePath })
await this.#handler.unlink(stateFilePath)
}
continue
}
pin(chain)
toMerge.push({ chain, isResuming: true })
}
for (const orphan of orphanDisks) {

View File

@@ -0,0 +1,279 @@
import { test } from 'node:test'
import { strict as assert } from 'node:assert'
import tmp from 'tmp'
import * as uuid from 'uuid'
import { getHandler } from '@xen-orchestra/fs'
import { normalize } from '@xen-orchestra/fs/path'
import { pFromCallback } from 'promise-toolbox'
import { rimraf } from 'rimraf'
import { Constants, VhdAbstract, VhdDirectory } from 'vhd-lib'
import { VHDFOOTER, VHDHEADER } from './tests.fixtures.mjs'
// `RemoteDiskLineage` is a `.mts` module: this is the TS ESM spelling of the sibling import, which
// resolves at runtime because the `test` script runs the built `dist/` copy of this file. It must
// NOT go through `../dist/`, which would make `tsc` pull the emitted declarations back in as
// inputs (TS5055) — the `*.integ.mjs` files can only do that because tsconfig excludes them.
/* eslint-disable n/no-missing-import */
import { RemoteDiskLineage } from './RemoteDiskLineage.mjs'
/* eslint-enable n/no-missing-import */
const { beforeEach, afterEach, describe } = test
let handler, tempDir, vdiDir
beforeEach(async () => {
tempDir = await pFromCallback(cb => tmp.dir(cb))
handler = getHandler({ url: `file://${tempDir}` })
await handler.sync()
// VmIncrementalBackupArchive passes a normalized path, and RemoteDiskLineage compares
// #vdiDir against normalized paths, so the fixture has to match
vdiDir = normalize(`xo-vm-backups/VMUUID/vdis/${uuid.v1()}/${uuid.v1()}`)
await handler.mktree(`${vdiDir}/data`)
})
afterEach(async () => {
await rimraf(tempDir)
await handler.forget()
})
// Creates an aliased VHD directory in `vdiDir`, chained onto `parent` when given.
async function generateDisk(name, { parent } = {}) {
const aliasPath = `${vdiDir}/${name}.alias.vhd`
const dataPath = `${vdiDir}/data/${name}.vhd`
await handler.mkdir(dataPath)
const vhd = new VhdDirectory(handler, dataPath)
vhd.header = { ...VHDHEADER }
vhd.footer = { ...VHDFOOTER, uuid: uuid.v1({}, Buffer.alloc(16)) }
if (parent !== undefined) {
vhd.header.parentUnicodeName = `${parent.name}.alias.vhd`
vhd.header.parentUuid = parent.vhd.footer.uuid
vhd.footer.diskType = Constants.DISK_TYPES.DIFFERENCING
} else {
vhd.footer.diskType = Constants.DISK_TYPES.DYNAMIC
}
await vhd.writeEntireBlock({ id: 0, buffer: Buffer.alloc(2 * 1024 * 1024 + 512, 1) })
await vhd.writeBlockAllocationTable()
await vhd.writeHeader()
await vhd.writeFooter()
await VhdAbstract.createAlias(handler, aliasPath, dataPath)
return { name, aliasPath, dataPath, vhd }
}
// Plants the merge state file a previous interrupted merge would have left on `parent`.
async function writeMergeState(parent, { chain, step = 'mergeBlocks' }) {
const statePath = `${vdiDir}/.${parent.name}.alias.vhd.merge.json`
await handler.writeFile(
statePath,
JSON.stringify({
child: { uuid: uuid.v1() },
parent: { uuid: uuid.v1() },
chain: chain.map(name => `${name}.alias.vhd`),
currentBlock: 0,
mergedDataSize: 0,
step,
diskSize: 0,
})
)
return statePath
}
async function cleanLineage({ activeDisks = [], lineageDir = vdiDir, merge = false } = {}) {
const warnings = []
const infos = []
const lineage = new RemoteDiskLineage(handler, lineageDir, {
remove: true,
merge,
logInfo: (message, data) => infos.push({ message, data }),
logWarn: (message, data) => warnings.push({ message, data }),
})
await lineage.init()
for (const disk of activeDisks) {
lineage.addActiveDiskPath(disk.aliasPath)
}
await lineage.clean({ remove: true, merge })
return { warnings, infos }
}
const exists = path => handler.list(vdiDir).then(files => files.includes(path.slice(vdiDir.length + 1)))
describe('RemoteDiskLineage.clean() interrupted merges', { concurrency: 1 }, () => {
test('drops the merge state and the disks when no descendant is still referenced', async () => {
const base = await generateDisk('base')
const child = await generateDisk('child', { parent: base })
const statePath = await writeMergeState(base, { chain: ['base', 'child'] })
// no active disk: the whole lineage is out of retention
const { warnings } = await cleanLineage()
assert.equal(await exists(statePath), false, 'merge state should be deleted')
assert.equal(await exists(base.aliasPath), false, 'base should be deleted')
assert.equal(await exists(child.aliasPath), false, 'child should be deleted')
assert.ok(
warnings.some(({ message }) => message === 'merge state on a fully orphaned lineage'),
'the dropped chain should be reported'
)
})
test('refuses to resume, and keeps everything, when the chain lost a disk before its cleanup step', async () => {
const base = await generateDisk('base')
const child = await generateDisk('child', { parent: base })
// `missing` was recorded in the chain but is no longer on the remote: merging base + child
// and renaming onto the tip would silently drop its blocks
const statePath = await writeMergeState(base, { chain: ['base', 'missing', 'child'] })
// merge enabled: the orphan loop must not pick the refused chain up and merge it afresh
const { warnings, infos } = await cleanLineage({ activeDisks: [child], merge: true })
assert.equal(
infos.some(({ message }) => message === 'Disk chain needs merging'),
false,
'no merge should be scheduled for a chain that was refused'
)
assert.equal(await exists(statePath), true, 'merge state should be kept while a backup uses the chain')
assert.equal(await exists(base.aliasPath), true, 'base should be kept')
assert.equal(await exists(child.aliasPath), true, 'child should be kept')
const warning = warnings.find(
({ message }) => message === 'merge chain lost disks before its cleanup step, refusing to resume'
)
assert.notEqual(warning, undefined, 'the incomplete chain should be reported')
assert.deepEqual(warning.data.missing, [`${vdiDir}/missing.alias.vhd`])
})
test('keeps every ancestor of a chain it refused to resume', async () => {
// several ancestors fell out of retention after the merge was interrupted (e.g. the retention
// was lowered): deleting any of them would leave `base`, a differencing disk, without a parent
const ggp = await generateDisk('ggp')
const gp = await generateDisk('gp', { parent: ggp })
const base = await generateDisk('base', { parent: gp })
const child = await generateDisk('child', { parent: base })
const statePath = await writeMergeState(base, { chain: ['base', 'missing', 'child'] })
const { warnings, infos } = await cleanLineage({ activeDisks: [child], merge: true })
assert.ok(
warnings.some(({ message }) => message === 'merge chain lost disks before its cleanup step, refusing to resume'),
'the incomplete chain should be reported'
)
assert.equal(
infos.some(({ message }) => message === 'Disk chain needs merging'),
false,
'neither the refused chain nor its ancestors should be merged'
)
assert.equal(await exists(ggp.aliasPath), true, 'ggp should be kept')
assert.equal(await exists(gp.aliasPath), true, 'gp should be kept')
assert.equal(await exists(statePath), true, 'merge state should be kept')
assert.equal(await exists(base.aliasPath), true, 'base should be kept')
assert.equal(await exists(child.aliasPath), true, 'child should be kept')
})
test('keeps every ancestor of a resumed chain, and merges them on the next run', async () => {
const ggp = await generateDisk('ggp')
const gp = await generateDisk('gp', { parent: ggp })
const base = await generateDisk('base', { parent: gp })
const child = await generateDisk('child', { parent: base })
const statePath = await writeMergeState(base, { chain: ['base', 'child'] })
// first run: only the interrupted merge is resumed, its ancestors are left alone
const firstRun = await cleanLineage({ activeDisks: [child], merge: true })
assert.ok(
firstRun.infos.some(({ message, data }) => message === 'merging disk chain' && data.chain.length === 2),
'the interrupted merge should be resumed'
)
assert.equal(await exists(statePath), false, 'merge state should be deleted once the merge completed')
assert.equal(await exists(base.aliasPath), false, 'base should be merged into child')
assert.equal(await exists(ggp.aliasPath), true, 'ggp should be kept')
assert.equal(await exists(gp.aliasPath), true, 'gp should be kept')
assert.equal(await exists(child.aliasPath), true, 'child should be kept')
// second run: the ancestors are merged into child as a regular chain
const secondRun = await cleanLineage({ activeDisks: [child], merge: true })
assert.deepEqual(
secondRun.warnings.filter(({ message }) => message === 'disk broken: parent missing or broken'),
[],
'child should still have its parents'
)
assert.ok(
secondRun.infos.some(({ message, data }) => message === 'merging disk chain' && data.chain.length === 3),
'ggp and gp should be merged into child'
)
assert.equal(await exists(ggp.aliasPath), false, 'ggp should be merged into child')
assert.equal(await exists(gp.aliasPath), false, 'gp should be merged into child')
assert.equal(await exists(child.aliasPath), true, 'child should be kept')
})
test('drops an unmergeable chain, state and disks, once nothing uses it', async () => {
const base = await generateDisk('base')
const missing = await generateDisk('missing', { parent: base })
const child = await generateDisk('child', { parent: missing })
const statePath = await writeMergeState(base, { chain: ['base', 'missing', 'child'] })
// the intermediate disk vanished mid-merge: child is now broken, so its backup is incomplete
// and never declares it active
await handler.unlink(missing.aliasPath)
await handler.rmtree(missing.dataPath)
const { warnings, infos } = await cleanLineage({ merge: true })
assert.equal(
infos.some(({ message }) => message === 'Disk chain needs merging'),
false,
'an unmergeable chain should not be merged'
)
assert.equal(await exists(statePath), false, 'merge state should be deleted')
assert.equal(await exists(base.aliasPath), false, 'base should be deleted')
assert.equal(await exists(child.aliasPath), false, 'child should be deleted')
assert.ok(
warnings.some(({ message }) => message === 'unmergeable merge chain on a fully orphaned lineage'),
'the dropped chain should be reported'
)
})
test('drops a merge state whose chain no longer has two disks to merge, during cleanup', async () => {
const base = await generateDisk('base')
// the cleanup step unlinks the children as it goes, so a gap there is expected
const statePath = await writeMergeState(base, { chain: ['base', 'alreadyUnlinked'], step: 'cleanup' })
const { warnings } = await cleanLineage({ activeDisks: [base] })
assert.equal(await exists(statePath), false, 'merge state should be deleted')
assert.equal(await exists(base.aliasPath), true, 'the active disk should be kept')
assert.ok(
warnings.some(({ message }) => message === 'merge state without a resumable chain'),
'the unresumable state should be reported'
)
})
test('keeps a resumable merge state and its disks', async () => {
const base = await generateDisk('base')
const child = await generateDisk('child', { parent: base })
const statePath = await writeMergeState(base, { chain: ['base', 'child'] })
const { warnings } = await cleanLineage({ activeDisks: [child] })
assert.equal(await exists(statePath), true, 'merge state should be kept')
assert.equal(await exists(base.aliasPath), true, 'base should be kept')
assert.equal(await exists(child.aliasPath), true, 'child should be kept')
assert.deepEqual(warnings, [], 'a resumable merge should not warn')
})
test('behaves the same when built with a non-normalized VDI directory', async () => {
const base = await generateDisk('base')
const child = await generateDisk('child', { parent: base })
const statePath = await writeMergeState(base, { chain: ['base', 'child'] })
// the constructor must normalize: otherwise #cleanOrphanDataFiles fails to exclude the VDI
// directory from the data directories it sweeps and deletes every unclaimed file in it,
// starting with the merge state
const { warnings } = await cleanLineage({ activeDisks: [child], lineageDir: vdiDir.replace(/^\//, '') })
assert.equal(await exists(statePath), true, 'merge state should be kept')
assert.equal(await exists(base.aliasPath), true, 'base should be kept')
assert.equal(await exists(child.aliasPath), true, 'child should be kept')
assert.deepEqual(warnings, [], 'nothing should be reported as orphaned')
})
})

View File

@@ -331,7 +331,7 @@ export class MergeRemoteDisk {
* @param {string[]} files - all file paths in vdiDir (prependDir: true)
* @param {Map<string, string>} uuidToPath - diskUuid: normalized disk path
* @param {Map<string, string>} childOf - parentPath: child path (from disk headers)
* @returns {Promise<Map<string, { stateFilePath: string, chain?: string[] }>>}
* @returns {Promise<Map<string, { stateFilePath: string, chain?: string[], step?: 'mergeBlocks' | 'cleanup' }>>}
*/
static async findInterruptedMerges(handler, vdiDir, files, uuidToPath, childOf) {
const STATE_FILE_RE = /^\.(.+)\.merge\.json$/
@@ -343,8 +343,12 @@ export class MergeRemoteDisk {
const parentPath = normalize(vdiDir + '/' + match[1])
let chain
let step
try {
const state = JSON.parse(await handler.readFile(filePath))
// disks are only removed during the cleanup step, so the caller needs the step to tell
// an expected gap in the chain from a lineage that lost data
step = state?.step ?? 'mergeBlocks'
if (Array.isArray(state?.chain)) {
chain = state.chain.map((/** @type {string} */ relPath) => normalize(resolveFromFile(filePath, relPath)))
} else {
@@ -357,7 +361,7 @@ export class MergeRemoteDisk {
warn("Merge state unreadable, can't restart merging.", error)
}
result.set(parentPath, { stateFilePath: filePath, chain })
result.set(parentPath, { stateFilePath: filePath, chain, step })
}
return result

View File

@@ -5,6 +5,7 @@
* @typedef {import('vhd-lib/Vhd/VhdFile.js').VhdFile} VhdFile
* @typedef {import('vhd-lib/_createFooterHeader.js').VhdFooter} VhdFooter
* @typedef {import('@xen-orchestra/disk-transform').DiskBlock} DiskBlock
* @typedef {import('vhd-lib/disk-consumer/compressedBlock.mjs').CompressedDiskBlock} CompressedDiskBlock
* @typedef {import('@xen-orchestra/fs').RemoteHandlerAbstract} RemoteHandlerAbstract
*
@@ -45,26 +46,36 @@ export class RemoteVhdDisk extends RemoteDisk {
*/
#blockSize = 2 * 1024 * 1024
/**
* @type {number}
*/
#bitmapSize = 512
/**
* @type {() => any}
*/
#dispose = () => {}
/**
* @type {string | undefined}
*/
#rawBlocksCompression
/**
* set in init(): this disk is a VHD directory using #rawBlocksCompression
* @type {boolean}
*/
#rawBlocks = false
/**
* @param {Object} params
* @param {RemoteHandlerAbstract} params.handler
* @param {string} params.path
* @param {string} [params.rawBlocksCompression] if this disk is a VHD directory using this compression, its blocks
* are read as stored (see CompressedDiskBlock), to be written as is in a VHD directory using the same compression.
* Otherwise (plain VHD, other compression), they are read normally
*/
constructor({ handler, path }) {
constructor({ handler, path, rawBlocksCompression }) {
super()
// @todo : ensure this is the full path from the root of the remote
this.#path = path
this.#handler = handler
this.#rawBlocksCompression = rawBlocksCompression
}
/**
@@ -87,6 +98,9 @@ export class RemoteVhdDisk extends RemoteDisk {
this.#vhd = undefined
throw Object.assign(new Error("Can't init vhd directory without using alias"), { code: 'NOT_SUPPORTED' })
}
// the disk may predate a change of the remote configuration: only use its raw blocks if they match
this.#rawBlocks =
this.#rawBlocksCompression !== undefined && this.#getCompressionType() === this.#rawBlocksCompression
if (!options.ignoreBlockIndexes) {
await this.#vhd.readBlockAllocationTable()
@@ -263,10 +277,7 @@ export class RemoteVhdDisk extends RemoteDisk {
if (this.#vhd === undefined) {
throw new Error(`can't call readBlock of a RemoteVhdDisk before init`)
}
await this.#vhd.writeEntireBlock({
id: diskBlock.index,
buffer: Buffer.concat([Buffer.alloc(this.#bitmapSize, 255), diskBlock.data]),
})
await this.#vhd.writeBlockData(diskBlock.index, diskBlock.data)
return this.getBlockSize()
}
@@ -274,12 +285,22 @@ export class RemoteVhdDisk extends RemoteDisk {
/**
* Reads a specific block from the VHD.
* @param {number} index
* @returns {Promise<DiskBlock>} diskBlock
* @returns {Promise<DiskBlock | CompressedDiskBlock>} diskBlock
*/
async readBlock(index) {
if (this.#vhd === undefined) {
throw new Error(`can't call readBlock of a RemoteVhdDisk before init`)
}
if (this.#rawBlocks) {
/** @type {CompressedDiskBlock} */
const block = {
index,
// only a VHD directory has a compression, checked in init()
data: await /** @type {VhdDirectory} */ (this.#vhd).readCompressedBlock(index),
compression: /** @type {string} */ (this.#rawBlocksCompression),
}
return block
}
const { data } = await this.#vhd.readBlock(index)
return {
index,

View File

@@ -7,9 +7,9 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@vates/async-each": "^1.0.3",
"@xen-orchestra/async-map": "^0.1.4",
"@xen-orchestra/backups": "^0.74.2",
"@xen-orchestra/fs": "^4.9.3",
"@xen-orchestra/async-map": "^0.1.5",
"@xen-orchestra/backups": "^0.78.0",
"@xen-orchestra/fs": "^4.10.0",
"filenamify": "^6.0.0",
"getopts": "^2.2.5",
"lodash": "^4.18.0",
@@ -28,7 +28,7 @@
"scripts": {
"postversion": "npm publish --access public"
},
"version": "1.1.14",
"version": "1.1.18",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Vates SAS",

View File

@@ -1,3 +1,10 @@
## Documentation
- [VM backups](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/VM%20backups/README.md): file structure on a remote, XAPI attributes, task logs, run description object, writer API
- [Incremental replication](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/VM%20backups/incrementalReplication.md): how CR selects its target VM and VDIs
- [Metadata backups](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/Metadata%20backups/README.md): file structure on a remote, structure of `metadata.json`, task logs
- [Health check](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/healthcheck/example.sh): example guest script reporting to xenstore
## Changing the default backup configuration
This library receives a backup configuration from `xo-server` or `xo-proxy`. This configuration is split between multiple properties:

View File

@@ -2,6 +2,7 @@ import assert from 'node:assert'
import { formatFilenameDate } from './_filenameDate.mjs'
import { importIncrementalVm } from './_incrementalVm.mjs'
import { liveMountXapiLabels } from './liveMountXapiLabels.mjs'
import { watchStreamSize } from './_watchStreamSize.mjs'
import { decorateClass } from '@vates/decorate-with'
import { Task } from '@vates/task'
@@ -10,6 +11,7 @@ import { dirname, join } from 'node:path'
import pickBy from 'lodash/pickBy.js'
import { defer } from 'golike-defer'
import { NegativeDisk } from '@xen-orchestra/disk-transform'
import { normalizeVdiRestoreTargets } from './_vdiRestoreTargets.mjs'
import { openDiskChain } from '@xen-orchestra/backup-archive/disks'
import { resetVmOtherConfig } from './_otherConfig.mjs'
@@ -25,20 +27,55 @@ async function resolveUuid(xapi, cache, uuid, type) {
return cache.get(uuid)
}
export class ImportVmBackup {
// live mounts created by this restore, with the host they are attached to, so a failure can
// release them and a success can report them to the caller, which is the one keeping track of them
#liveMounts = []
// ref of the host serving the live mounts, resolved once on the destination pool
#liveMountHostRef
/**
* @param {object} params
* @param {object} params.adapter - remote adapter of the backup repository
* @param {object} params.metadata - metadata of the backup to restore
* @param {string} params.srUuid - SR the disks are restored to, unless a per disk target says otherwise
* @param {object} params.xapi - XAPI connection of the pool owning `srUuid`
* @param {object} [params.liveMount] - how to serve a disk from the backup repository instead of
* copying it, injected by the caller since a live mount outlives the restore and cannot be run
* from this package
* @param {({ cacheSrUuid, diskPath, hostId, xapiLabels }) => Promise<{ id: string, vdiUuid: string }>} params.liveMount.mountDisk
* `xapiLabels` are those of {@link liveMountXapiLabels}, `cacheSrUuid` makes the mount read/write
* @param {(id: string) => Promise<void>} params.liveMount.unmountDisk
* @param {object} [params.settings]
*/
constructor({
adapter,
liveMount,
metadata,
srUuid,
xapi,
settings: { additionalVmTag, newMacAddresses, mapVdisSrs = {}, useDifferentialRestore = false } = {},
settings: { additionalVmTag, newMacAddresses, mapVdisSrs = {}, useDifferentialRestore = false, vmNamePrefix } = {},
}) {
this._adapter = adapter
this._importIncrementalVmSettings = { additionalVmTag, newMacAddresses, mapVdisSrs, useDifferentialRestore }
this._importIncrementalVmSettings = {
additionalVmTag,
newMacAddresses,
useDifferentialRestore,
vmNamePrefix,
}
this._liveMount = liveMount
this._metadata = metadata
this._srUuid = srUuid
this._vdiRestoreTargets = normalizeVdiRestoreTargets(mapVdisSrs, { useDifferentialRestore })
this._xapi = xapi
}
/** SR a disk must be restored to, the restore's default one unless its target names another */
#getTargetSrUuid(vdiUuid) {
const target = this._vdiRestoreTargets.get(vdiUuid)
return (target.type === 'restore' ? target.sr : undefined) ?? this._srUuid
}
async #getPathOfVdiSnapshot(snapshotUuid) {
const metadata = this._metadata
if (this._pathToVdis === undefined) {
@@ -57,13 +94,13 @@ export class ImportVmBackup {
return this._pathToVdis.get(snapshotUuid)
}
async _reuseNearestSnapshot($defer, ignoredVdis) {
async _reuseNearestSnapshot($defer, excludedVdiUuids) {
const metadata = this._metadata
const { mapVdisSrs } = this._importIncrementalVmSettings
const { vbds, vhds, vifs, vm, vmSnapshot, vtpms } = metadata
const disks = {}
const metadataDir = dirname(metadata._filename)
const vdis = ignoredVdis === undefined ? metadata.vdis : pickBy(metadata.vdis, vdi => !ignoredVdis.has(vdi.uuid))
const vdis =
excludedVdiUuids === undefined ? metadata.vdis : pickBy(metadata.vdis, vdi => !excludedVdiUuids.has(vdi.uuid))
for (const [vdiRef, vdi] of Object.entries(vdis)) {
const vhdPath = join(metadataDir, vhds[vdiRef])
@@ -100,8 +137,11 @@ export class ImportVmBackup {
debug('no backup linked to this snapshot')
continue
}
if (snapshot.$SR.uuid !== (mapVdisSrs[vdi.$snapshot_of$uuid] ?? this._srUuid)) {
debug('not restored on the same SR', { snapshotSr: snapshot.$SR.uuid, mapVdisSrs, srUuid: this._srUuid })
// reusing a snapshot means cloning it, and a clone lands on the SR of its source: a
// snapshot which is not on the SR this disk is restored to is not a usable base
const targetSrUuid = this.#getTargetSrUuid(vdi.uuid)
if (snapshot.$SR.uuid !== targetSrUuid) {
debug('not restored on the same SR', { snapshotSr: snapshot.$SR.uuid, targetSrUuid })
continue
}
@@ -194,52 +234,204 @@ export class ImportVmBackup {
}
}
async #decorateIncrementalVmMetadata() {
const { additionalVmTag, mapVdisSrs, useDifferentialRestore } = this._importIncrementalVmSettings
async _decorateIncrementalVmMetadata() {
const { additionalVmTag, useDifferentialRestore } = this._importIncrementalVmSettings
const targets = this._vdiRestoreTargets
this.#checkTargetsMatchBackup()
// a live mounted disk stays on the backup repository and is served from there, so it is left
// out of the disks to read, exactly like an ignored one, then added back below
const liveMountedVdiUuids = targets.getLiveMountedVdiUuids()
const excludedVdiUuids = new Set([...targets.getIgnoredVdiUuids(), ...liveMountedVdiUuids])
const ignoredVdis = new Set(
Object.entries(mapVdisSrs)
.filter(([_, srUuid]) => srUuid === null)
.map(([vdiUuid]) => vdiUuid)
)
let backup
if (useDifferentialRestore) {
backup = await this._reuseNearestSnapshot(ignoredVdis)
backup = await this._reuseNearestSnapshot(excludedVdiUuids)
} else {
backup = await this._adapter.readIncrementalVmBackup(this._metadata, ignoredVdis)
backup = await this._adapter.readIncrementalVmBackup(this._metadata, excludedVdiUuids)
}
const xapi = this._xapi
const cache = new Map()
const mapVdisSrRefs = {}
if (additionalVmTag !== undefined) {
backup.vm.tags.push(additionalVmTag)
}
for (const [vdiUuid, srUuid] of Object.entries(mapVdisSrs)) {
mapVdisSrRefs[vdiUuid] = await resolveUuid(xapi, cache, srUuid, 'SR')
for (const vdi of Object.values(backup.vdis)) {
vdi.SR = await resolveUuid(xapi, cache, this.#getTargetSrUuid(vdi.uuid), 'SR')
}
const srRef = await resolveUuid(xapi, cache, this._srUuid, 'SR')
Object.values(backup.vdis).forEach(vdi => {
vdi.SR = mapVdisSrRefs[vdi.uuid] ?? srRef
})
// after the SR resolution: a live mounted VDI already exists, it is not created on any SR
await this.#addLiveMountedVdis(backup)
return backup
}
async run() {
/**
* Reject a restore whose targets name disks this backup does not contain.
*
* An unknown uuid is a mistake in every case — a stale `mapVdisSrs`, a uuid taken from the
* running VM instead of the backup — but it is only fatal for a live mount: that target is new,
* and silently restoring the disk the usual way would copy the very data the caller asked not
* to. The historical targets keep their lenient behavior, so a restore which used to work still
* does, and say so in the logs.
*/
#checkTargetsMatchBackup() {
const backedUpVdiUuids = new Set(Object.values(this._metadata.vdis ?? {}).map(vdi => vdi.uuid))
const unknownLiveMounted = []
for (const [vdiUuid, target] of this._vdiRestoreTargets.entries()) {
if (backedUpVdiUuids.has(vdiUuid)) {
continue
}
if (target.type === 'live-mount') {
unknownLiveMounted.push(vdiUuid)
} else {
warn('restore target of a disk which is not in this backup, ignoring it', {
type: target.type,
vdiUuid,
})
}
}
if (unknownLiveMounted.length !== 0) {
throw new Error(
`these disks cannot be live mounted, they are not in this backup: ${unknownLiveMounted.join(', ')}`
)
}
}
/**
* Ref, on the pool the VM is restored to, of the host serving the live mounts.
*
* A live mount plugs its SR into a single host, which therefore has to belong to that pool: the
* uuid is resolved here, once and before anything is mounted, so a host picked on another pool
* is rejected with an explicit message instead of a bare `UUID_INVALID` raised halfway through
* the restore.
*/
async #resolveLiveMountHostRef() {
if (this.#liveMountHostRef === undefined) {
// also validates that every live mount of this restore uses the same host
const hostId = this._vdiRestoreTargets.getLiveMountHost()
try {
this.#liveMountHostRef = await this._xapi.call('host.get_by_uuid', hostId)
} catch (error) {
if (error?.code !== 'UUID_INVALID') {
throw error
}
throw new Error(
`host ${hostId} cannot live mount a disk of this restore: it does not belong to the pool the VM is restored to`,
{ cause: error }
)
}
}
return this.#liveMountHostRef
}
/**
* Mount every disk whose target asks for it and add it to the backup to import, as an existing
* VDI to attach instead of a disk to transfer.
*/
async #addLiveMountedVdis(backup) {
const vdiUuids = this._vdiRestoreTargets.getLiveMountedVdiUuids()
if (vdiUuids.size === 0) {
return
}
const liveMount = this._liveMount
if (liveMount === undefined) {
throw new Error('live mounting a disk during a restore is not supported here')
}
const xapi = this._xapi
const metadata = this._metadata
const metadataDir = dirname(metadata._filename)
// before the first mount, so a host which cannot serve them leaves nothing behind
await this.#resolveLiveMountHostRef()
const hostId = this._vdiRestoreTargets.getLiveMountHost()
for (const [vdiRef, vdi] of Object.entries(metadata.vdis)) {
// a disk of the backup which is restored the usual way, or not at all; a target naming a
// disk absent from the backup has already been rejected by `#checkTargetsMatchBackup`
if (!vdiUuids.has(vdi.uuid)) {
continue
}
const diskPath = join(metadataDir, metadata.vhds[vdiRef])
const cacheSrUuid = this._vdiRestoreTargets.get(vdi.uuid).cacheSr
const readWrite = cacheSrUuid !== undefined
const xapiLabels = liveMountXapiLabels({
readWrite,
timestamp: metadata.timestamp,
vdiNameLabel: vdi.name_label,
vmNameLabel: metadata.vm.name_label,
})
const mount = await liveMount.mountDisk({ cacheSrUuid, diskPath, hostId, xapiLabels })
this.#liveMounts.push({ ...mount, hostId })
info('disk live mounted', { cacheSrUuid, diskPath, hostId, mountId: mount.id, vdiUuid: vdi.uuid })
const record = {
...vdi,
liveMountedVdiRef: await xapi.call('VDI.get_by_uuid', mount.vdiUuid),
liveMountedReadOnly: !readWrite,
}
// it is attached as is: there is nothing to clone it from, nothing to transfer into it, and
// no SR to create it on — `SR` still holds the ref it had on the backed up pool
delete record.baseVdi
delete record.SR
backup.vdis[vdiRef] = record
}
}
/**
* Point the restored VM at the host serving its live mounted disks.
*
* Their SR is plugged on that host only, so the VM can run nowhere else.
*/
async #setLiveMountAffinity(vmRef) {
if (this.#liveMounts.length === 0) {
return
}
await this._xapi.call('VM.set_affinity', vmRef, await this.#resolveLiveMountHostRef())
}
/**
* Release the live mounts created by this restore.
*
* Best effort: this runs while a restore is already failing, and a mount left behind must not
* hide the error which caused it.
*/
async #releaseLiveMounts() {
for (const { id } of this.#liveMounts.splice(0)) {
try {
await this._liveMount.unmountDisk(id)
} catch (error) {
warn('failed to unmount a live mounted disk', { error, mountId: id })
}
}
}
async run($defer) {
const adapter = this._adapter
const metadata = this._metadata
const isFull = metadata.mode === 'full'
const sizeContainer = { size: 0 }
const { newMacAddresses } = this._importIncrementalVmSettings
const { newMacAddresses, vmNamePrefix, additionalVmTag } = this._importIncrementalVmSettings
let backup
if (isFull) {
if (this._vdiRestoreTargets.getLiveMountedVdiUuids().size > 0) {
// a full backup is a single XVA stream: there is no per disk handling at all
throw new Error('live mounting a disk is only supported when restoring an incremental backup')
}
backup = await adapter.readFullVmBackup(metadata)
watchStreamSize(backup, sizeContainer)
} else {
assert.strictEqual(metadata.mode, 'delta')
backup = await this.#decorateIncrementalVmMetadata()
// the mounts are created here, before the import, and outlive this restore: only a failure
// releases them, a successful one hands their ids back to the caller
$defer.onFailure(() => this.#releaseLiveMounts())
backup = await this._decorateIncrementalVmMetadata()
}
return Task.run(
@@ -250,10 +442,20 @@ export class ImportVmBackup {
const xapi = this._xapi
const srRef = await xapi.call('SR.get_by_uuid', this._srUuid)
const onVmCreation =
vmNamePrefix === undefined && additionalVmTag === undefined
? null
: vm =>
Promise.all([
vmNamePrefix !== undefined && vm.set_name_label(vmNamePrefix + metadata.vm.name_label),
additionalVmTag !== undefined && vm.add_tags(additionalVmTag),
])
const vmRef = isFull
? await xapi.VM_import(backup, srRef)
? await xapi.VM_import(backup, srRef, onVmCreation)
: await importIncrementalVm(backup, await xapi.getRecord('SR', srRef), {
newMacAddresses,
vmNamePrefix,
})
let size = 0
if (isFull) {
@@ -276,19 +478,21 @@ export class ImportVmBackup {
xapi.call(
'VM.set_name_label',
vmRef,
`${metadata.vm.name_label} (${formatFilenameDate(metadata.timestamp)})`
`${vmNamePrefix ?? ''}${metadata.vm.name_label} (${formatFilenameDate(metadata.timestamp)})`
),
xapi.call('VM.set_name_description', vmRef, desc),
resetVmOtherConfig(xapi, vmRef),
this.#setLiveMountAffinity(vmRef),
])
return {
size,
id: await xapi.getField('VM', vmRef, 'uuid'),
liveMounts: [...this.#liveMounts],
}
}
)
}
}
decorateClass(ImportVmBackup, { _reuseNearestSnapshot: defer })
decorateClass(ImportVmBackup, { _reuseNearestSnapshot: defer, run: defer })

View File

@@ -0,0 +1,306 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import pickBy from 'lodash/pickBy.js'
import { ImportVmBackup } from './ImportVmBackup.mjs'
const VM_UUID = 'vm-uuid'
const JOB_ID = 'job-id'
const DEFAULT_SR = 'default-sr-uuid'
const OTHER_SR = 'other-sr-uuid'
const HOST = 'host-uuid'
// two disks, each identified in the backup by the uuid of the snapshot it was taken from
const SYSTEM = { ref: 'OpaqueRef:system', uuid: 'snapshot-vdi-system', liveUuid: 'live-vdi-system' }
const DATA = { ref: 'OpaqueRef:data', uuid: 'snapshot-vdi-data', liveUuid: 'live-vdi-data' }
const metadataFilename = `/xo-vm-backups/${VM_UUID}/20250801T080832Z.json`
const vhdPath = disk => `vdis/${JOB_ID}/${disk.liveUuid}/20250801T080832Z.vhd`
function makeMetadata() {
return {
_filename: metadataFilename,
jobId: JOB_ID,
mode: 'delta',
timestamp: 1754035712000,
vbds: {},
vdis: {
// `SR` is the ref the VDI had on the pool the backup was taken from
[SYSTEM.ref]: { uuid: SYSTEM.uuid, $snapshot_of$uuid: SYSTEM.liveUuid, name_label: 'system', SR: 'OpaqueRef:sr' },
[DATA.ref]: { uuid: DATA.uuid, $snapshot_of$uuid: DATA.liveUuid, name_label: 'data', SR: 'OpaqueRef:sr' },
},
vhds: {
[SYSTEM.ref]: vhdPath(SYSTEM),
[DATA.ref]: vhdPath(DATA),
},
vifs: {},
vm: { uuid: VM_UUID, name_label: 'a vm', tags: [] },
vmSnapshot: { suspend_VDI: 'OpaqueRef:NULL' },
vtpms: [],
}
}
// mimics `RemoteAdapter#readIncrementalVmBackup`: excluded VDIs are dropped, the others get a disk
function makeAdapter(metadata) {
return {
_handler: {},
readIncrementalVmBackup(_metadata, excludedVdiUuids) {
const vdis = pickBy(metadata.vdis, vdi => !excludedVdiUuids.has(vdi.uuid))
const disks = {}
for (const ref of Object.keys(vdis)) {
disks[ref] = { path: metadata.vhds[ref] }
}
return { disks, vbds: metadata.vbds, vdis, version: '1.0.0', vifs: {}, vm: metadata.vm, vtpms: [] }
},
}
}
// `unknownUuids` are refused exactly like XAPI refuses a uuid of another pool
const makeXapi = ({ unknownUuids = [] } = {}) => ({
call(method, uuid) {
const [type, getter] = method.split('.')
assert.equal(getter, 'get_by_uuid')
if (unknownUuids.includes(uuid)) {
const error = new Error(`UUID_INVALID(${type}, ${uuid})`)
error.code = 'UUID_INVALID'
throw error
}
return `${type}_REF:${uuid}`
},
})
function makeLiveMount() {
const mounted = []
return {
mounted,
unmounted: [],
mountDisk(params) {
mounted.push(params)
return { id: `mount-${mounted.length}`, vdiUuid: `mounted-vdi-${mounted.length}` }
},
unmountDisk(id) {
this.unmounted.push(id)
},
}
}
function makeImporter({ mapVdisSrs, unknownUuids, useDifferentialRestore = false, withoutLiveMount = false } = {}) {
const liveMount = withoutLiveMount ? undefined : makeLiveMount()
const metadata = makeMetadata()
const importer = new ImportVmBackup({
adapter: makeAdapter(metadata),
liveMount,
metadata,
settings: { mapVdisSrs, useDifferentialRestore },
srUuid: DEFAULT_SR,
xapi: makeXapi({ unknownUuids }),
})
return { importer, liveMount, metadata }
}
describe('ImportVmBackup#_decorateIncrementalVmMetadata()', () => {
it('restores every disk onto the default SR when nothing is mapped', async () => {
const { importer } = makeImporter()
const backup = await importer._decorateIncrementalVmMetadata()
assert.deepEqual(Object.keys(backup.vdis), [SYSTEM.ref, DATA.ref])
for (const vdi of Object.values(backup.vdis)) {
assert.equal(vdi.SR, `SR_REF:${DEFAULT_SR}`)
}
})
it('honors a per disk SR, in both the legacy and the current shape', async () => {
for (const mapVdisSrs of [{ [DATA.uuid]: OTHER_SR }, { [DATA.uuid]: { type: 'restore', sr: OTHER_SR } }]) {
const { importer } = makeImporter({ mapVdisSrs })
const backup = await importer._decorateIncrementalVmMetadata()
assert.equal(backup.vdis[SYSTEM.ref].SR, `SR_REF:${DEFAULT_SR}`)
assert.equal(backup.vdis[DATA.ref].SR, `SR_REF:${OTHER_SR}`)
}
})
it('leaves out an ignored disk, in both the legacy and the current shape', async () => {
for (const mapVdisSrs of [{ [DATA.uuid]: null }, { [DATA.uuid]: { type: 'ignore' } }]) {
const { importer } = makeImporter({ mapVdisSrs })
const backup = await importer._decorateIncrementalVmMetadata()
assert.deepEqual(Object.keys(backup.vdis), [SYSTEM.ref])
assert.deepEqual(Object.keys(backup.disks), [SYSTEM.ref])
}
})
describe('live mount', () => {
const mapVdisSrs = { [DATA.uuid]: { type: 'live-mount', host: HOST } }
it('mounts the disk instead of reading it', async () => {
const { importer, liveMount } = makeImporter({ mapVdisSrs })
const backup = await importer._decorateIncrementalVmMetadata()
assert.deepEqual(liveMount.mounted, [
{
// not cached: read only
cacheSrUuid: undefined,
diskPath: `/xo-vm-backups/${VM_UUID}/${vhdPath(DATA)}`,
hostId: HOST,
// named after the backed up VM and disk, not after the file on the backup repository
xapiLabels: {
srNameLabel: '[XO live mount] a vm (20250801T080832Z)',
vdiNameLabel: 'data',
vdiNameDescription: 'Read-only live mount of a vm (20250801T080832Z), removed on unmount.',
},
},
])
// no data is transferred for it
assert.deepEqual(Object.keys(backup.disks), [SYSTEM.ref])
})
it('attaches the mounted VDI and gives it no SR', async () => {
const { importer } = makeImporter({ mapVdisSrs })
const backup = await importer._decorateIncrementalVmMetadata()
const vdi = backup.vdis[DATA.ref]
assert.equal(vdi.liveMountedVdiRef, 'VDI_REF:mounted-vdi-1')
assert.equal(vdi.liveMountedReadOnly, true)
assert.equal(vdi.SR, undefined)
assert.equal(vdi.uuid, DATA.uuid)
// the other disk is restored as usual
assert.equal(backup.vdis[SYSTEM.ref].SR, `SR_REF:${DEFAULT_SR}`)
assert.equal(backup.vdis[SYSTEM.ref].liveMountedVdiRef, undefined)
})
it('mounts a cached disk read/write, and says so in its names', async () => {
const { importer, liveMount } = makeImporter({
mapVdisSrs: { [DATA.uuid]: { type: 'live-mount', host: HOST, cacheSr: OTHER_SR } },
})
const backup = await importer._decorateIncrementalVmMetadata()
const [mounted] = liveMount.mounted
assert.equal(mounted.cacheSrUuid, OTHER_SR)
assert.equal(mounted.xapiLabels.srNameLabel, '[XO live restore] a vm (20250801T080832Z)')
assert.equal(backup.vdis[DATA.ref].liveMountedReadOnly, false)
})
it('rejects live mounts spread over several hosts', async () => {
const { importer } = makeImporter({
mapVdisSrs: {
[SYSTEM.uuid]: { type: 'live-mount', host: HOST },
[DATA.uuid]: { type: 'live-mount', host: 'another-host' },
},
})
await assert.rejects(() => importer._decorateIncrementalVmMetadata(), /must use the same host/)
})
it('rejects a live mount when the caller cannot serve one', async () => {
const { importer } = makeImporter({ mapVdisSrs, withoutLiveMount: true })
await assert.rejects(() => importer._decorateIncrementalVmMetadata(), /not supported here/)
})
it('rejects a live mount of a disk which is not in this backup', async () => {
const { importer, liveMount } = makeImporter({
mapVdisSrs: { 'not-in-this-backup': { type: 'live-mount', host: HOST } },
})
await assert.rejects(
() => importer._decorateIncrementalVmMetadata(),
/cannot be live mounted, they are not in this backup: not-in-this-backup/
)
assert.deepEqual(liveMount.mounted, [])
})
it('rejects a host which is not on the pool the VM is restored to', async () => {
const { importer, liveMount } = makeImporter({ mapVdisSrs, unknownUuids: [HOST] })
await assert.rejects(() => importer._decorateIncrementalVmMetadata(), /does not belong to the pool/)
// the host is resolved first, so nothing is left mounted behind
assert.deepEqual(liveMount.mounted, [])
})
})
it('still restores when a disk of `mapVdisSrs` is not in this backup', async () => {
// a stale mapping must not break a restore which used to work
const { importer } = makeImporter({ mapVdisSrs: { 'not-in-this-backup': OTHER_SR } })
const backup = await importer._decorateIncrementalVmMetadata()
assert.deepEqual(Object.keys(backup.vdis), [SYSTEM.ref, DATA.ref])
})
})
describe('ImportVmBackup#_reuseNearestSnapshot()', () => {
// a snapshot whose backup is the very file being restored: `_reuseNearestSnapshot` reuses it as
// is, which is the one branch that opens no VHD chain
function makeDifferentialImporter({ mapVdisSrs, snapshotSrUuid }) {
const metadata = makeMetadata()
const adapter = makeAdapter(metadata)
adapter.listVmBackups = () => [{ vdis: metadata.vdis, vhds: metadata.vhds }]
// reusing a snapshot as is opens nothing: any attempt to open the backup chain means the disk
// is about to be transferred instead
adapter._handler = {
openFile() {
throw new Error('would transfer the disk')
},
}
const snapshots = {
'OpaqueRef:snapshot': {
uuid: DATA.uuid,
type: 'user',
snapshot_time: 1,
$SR: { uuid: snapshotSrUuid },
},
}
const xapi = {
...makeXapi(),
getRecordByUuid(type, uuid) {
assert.equal(type, 'VDI')
return uuid === DATA.liveUuid ? { snapshots: Object.keys(snapshots) } : undefined
},
getRecord(type, ref) {
assert.equal(type, 'VDI')
return snapshots[ref]
},
}
return new ImportVmBackup({
adapter,
metadata,
settings: { mapVdisSrs, useDifferentialRestore: true },
srUuid: DEFAULT_SR,
xapi,
})
}
it('reuses a snapshot sitting on the SR that disk is restored to', async () => {
// the disk is remapped to another SR, and the snapshot is there too
const importer = makeDifferentialImporter({
mapVdisSrs: { [DATA.uuid]: OTHER_SR, [SYSTEM.uuid]: null },
snapshotSrUuid: OTHER_SR,
})
const backup = await importer._reuseNearestSnapshot(new Set([SYSTEM.uuid]))
assert.equal(backup.vdis[DATA.ref].baseVdi.uuid, DATA.uuid)
assert.equal(backup.disks[DATA.ref], undefined)
})
it('skips a snapshot sitting on another SR', async () => {
// the snapshot is on the default SR, but that disk is restored to another one: cloning it
// would land on the wrong SR, so it must be transferred instead
const importer = makeDifferentialImporter({
mapVdisSrs: { [DATA.uuid]: OTHER_SR, [SYSTEM.uuid]: null },
snapshotSrUuid: DEFAULT_SR,
})
await assert.rejects(() => importer._reuseNearestSnapshot(new Set([SYSTEM.uuid])), /would transfer the disk/)
})
})

View File

@@ -14,6 +14,13 @@ npm install --save @xen-orchestra/backups
## Usage
## Documentation
- [VM backups](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/VM%20backups/README.md): file structure on a remote, XAPI attributes, task logs, run description object, writer API
- [Incremental replication](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/VM%20backups/incrementalReplication.md): how CR selects its target VM and VDIs
- [Metadata backups](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/Metadata%20backups/README.md): file structure on a remote, structure of `metadata.json`, task logs
- [Health check](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/healthcheck/example.sh): example guest script reporting to xenstore
## Changing the default backup configuration
This library receives a backup configuration from `xo-server` or `xo-proxy`. This configuration is split between multiple properties:

View File

@@ -40,10 +40,10 @@ afterEach(async () => {
const uniqueId = () => uuid.v1()
const uniqueIdBuffer = () => uuid.v1({}, Buffer.alloc(16))
async function getAdapter({ useVhdDirectory = false, vhdDirectoryCompression, remoteCompressionType } = {}) {
async function getAdapter({ useVhdDirectory = false, remoteCompressionType } = {}) {
handler = getHandler({ url: `file://${tempDir}`, useVhdDirectory, compressionType: remoteCompressionType })
await handler.sync()
return new RemoteAdapter(handler, { vhdDirectoryCompression })
return new RemoteAdapter(handler)
}
const listVmDir = () => handler.list(rootPath, { ignoreMissing: true })
@@ -145,7 +145,8 @@ describe('RemoteAdapter#isMergeableParent', { concurrency: 1 }, () => {
test(`diskIsDirectory=${diskIsDirectory} useVhdDirectory=${useVhdDirectory} compressionMatches=${compressionMatches} uuidMatches=${uuidMatches} -> ${expected}`, async () => {
const targetCompression = 'brotli'
const adapter = await getAdapter({ useVhdDirectory, vhdDirectoryCompression: targetCompression })
// no compressionType on the remote: brotli
const adapter = await getAdapter({ useVhdDirectory })
const targetUuid = uniqueIdBuffer()
await generateVhd(`${basePath}/disk.vhd`, {
@@ -164,37 +165,32 @@ describe('RemoteAdapter#isMergeableParent', { concurrency: 1 }, () => {
})
describe('RemoteAdapter#isMergeableParent compression resolution', { concurrency: 1 }, () => {
test('the per-remote compressionType override wins over the adapter default', async () => {
const adapter = await getAdapter({
useVhdDirectory: true,
vhdDirectoryCompression: 'gzip', // config.toml-style default
remoteCompressionType: 'brotli', // per-remote override, should be the one actually compared against
})
test('the compressionType of the remote is the one compared against', async () => {
const adapter = await getAdapter({ useVhdDirectory: true, remoteCompressionType: 'gzip' })
const targetUuid = uniqueIdBuffer()
await generateVhd(`${basePath}/disk.vhd`, { mode: 'directory', compression: 'gzip', uuid: targetUuid })
assert.equal(await adapter.isMergeableParent(targetUuid, `${basePath}/disk.vhd.alias.vhd`), true)
})
test('a disk compressed with another codec than the remote is rejected', async () => {
const adapter = await getAdapter({ useVhdDirectory: true, remoteCompressionType: 'gzip' })
const targetUuid = uniqueIdBuffer()
await generateVhd(`${basePath}/disk.vhd`, { mode: 'directory', compression: 'brotli', uuid: targetUuid })
assert.equal(await adapter.isMergeableParent(targetUuid, `${basePath}/disk.vhd.alias.vhd`), false)
})
test('a remote without compressionType uses brotli', async () => {
const adapter = await getAdapter({ useVhdDirectory: true })
const targetUuid = uniqueIdBuffer()
await generateVhd(`${basePath}/disk.vhd`, { mode: 'directory', compression: 'brotli', uuid: targetUuid })
assert.equal(await adapter.isMergeableParent(targetUuid, `${basePath}/disk.vhd.alias.vhd`), true)
})
test('a disk compressed with the adapter default (not the override) is rejected', async () => {
const adapter = await getAdapter({
useVhdDirectory: true,
vhdDirectoryCompression: 'gzip',
remoteCompressionType: 'brotli',
})
const targetUuid = uniqueIdBuffer()
// disk uses the config default, not the remote override that's actually in effect
await generateVhd(`${basePath}/disk.vhd`, { mode: 'directory', compression: 'gzip', uuid: targetUuid })
assert.equal(await adapter.isMergeableParent(targetUuid, `${basePath}/disk.vhd.alias.vhd`), false)
})
test("a remote-level 'none' override means no compression, ignoring the adapter default", async () => {
const adapter = await getAdapter({
useVhdDirectory: true,
vhdDirectoryCompression: 'brotli',
remoteCompressionType: 'none',
})
test("a remote-level 'none' means no compression", async () => {
const adapter = await getAdapter({ useVhdDirectory: true, remoteCompressionType: 'none' })
const targetUuid = uniqueIdBuffer()
// no compression option => VhdDirectory's own getCompressionType() is undefined, matching 'none'
await generateVhd(`${basePath}/disk.vhd`, { mode: 'directory', uuid: targetUuid })
@@ -323,3 +319,105 @@ describe('RemoteAdapter#writeVhd validated by checkDisk', { concurrency: 1 }, ()
})
}
})
// IncrementalRemote runner when source and targets use the same compression: the blocks are copied as stored
describe('RemoteAdapter raw blocks copy', { concurrency: 1 }, () => {
// copy a disk the way the IncrementalRemote runner does, and check the target content
async function copyAndCheck(adapter, sourcePath, { rawBlocksCompression, expectedCompression }) {
const targetPath = `${basePath}/${adapter.getVhdFileName('target')}`
const source = await adapter._createVhdDisk(handler, sourcePath, { useChain: false, rawBlocksCompression })
await adapter.writeVhd(targetPath, source, {
validator: tmpPath => checkDisk(handler, tmpPath),
uuid: uniqueIdBuffer(),
})
await Disposable.use(openVhd(handler, targetPath), async vhd => {
assert.equal(vhd.compressionType, expectedCompression)
await vhd.readBlockAllocationTable()
assert.ok(vhd.containsBlock(0))
assert.ok(!vhd.containsBlock(1))
const { data } = await vhd.readBlock(2)
assert.ok(
data.every(byte => byte === 0x42),
'block 2 should contain the source disk data'
)
})
}
for (const compression of ['brotli', 'gzip', 'none']) {
test(`${compression}: a disk read with the same compression is copied as stored, readable and identical`, async () => {
const adapter = await getAdapter({ useVhdDirectory: true, remoteCompressionType: compression })
const sourcePath = `${basePath}/${adapter.getVhdFileName('source')}`
await adapter.writeVhd(sourcePath, new MockDisk(3, [0, 2], 0x42), { uuid: uniqueIdBuffer() })
const source = await adapter._createVhdDisk(handler, sourcePath, {
useChain: false,
rawBlocksCompression: compression,
})
const block = await source.readBlock(2)
await source.close()
assert.equal(block.compression, compression)
if (compression !== 'none') {
assert.ok(block.data.length < 2 * 1024 * 1024, 'the block should be read compressed')
}
await copyAndCheck(adapter, sourcePath, { rawBlocksCompression: compression, expectedCompression: compression })
})
}
test('a disk written before a compression change is read normally and copied', async () => {
const gzipAdapter = await getAdapter({ useVhdDirectory: true, remoteCompressionType: 'gzip' })
const sourcePath = `${basePath}/${gzipAdapter.getVhdFileName('source')}`
await gzipAdapter.writeVhd(sourcePath, new MockDisk(3, [0, 2], 0x42), { uuid: uniqueIdBuffer() })
await handler.forget()
const brotliAdapter = await getAdapter({ useVhdDirectory: true, remoteCompressionType: 'brotli' })
const source = await brotliAdapter._createVhdDisk(handler, sourcePath, {
useChain: false,
rawBlocksCompression: 'brotli',
})
const block = await source.readBlock(2)
await source.close()
assert.equal(block.compression, undefined)
assert.equal(block.data.length, 2 * 1024 * 1024)
await copyAndCheck(brotliAdapter, sourcePath, { rawBlocksCompression: 'brotli', expectedCompression: 'brotli' })
})
test('a VHD file written before the switch to VHD directories is read normally and copied', async () => {
const fileAdapter = await getAdapter({ useVhdDirectory: false })
const sourcePath = `${basePath}/${fileAdapter.getVhdFileName('source')}`
await fileAdapter.writeVhd(sourcePath, new MockDisk(3, [0, 2], 0x42), { uuid: uniqueIdBuffer() })
await handler.forget()
const directoryAdapter = await getAdapter({ useVhdDirectory: true, remoteCompressionType: 'brotli' })
await copyAndCheck(directoryAdapter, sourcePath, { rawBlocksCompression: 'brotli', expectedCompression: 'brotli' })
})
// blocks as read from a VHD directory using `compression`, without their real content: only the checks are tested
class MockCompressedDisk extends MockDisk {
#compression
constructor(compression, ...args) {
super(...args)
this.#compression = compression
}
async readBlock(index) {
return { ...(await super.readBlock(index)), compression: this.#compression }
}
}
test('a VHD directory target refuses blocks compressed differently', async () => {
const adapter = await getAdapter({ useVhdDirectory: true, remoteCompressionType: 'brotli' })
await assert.rejects(
adapter.writeVhd(
`${basePath}/${adapter.getVhdFileName('target')}`,
new MockCompressedDisk('gzip', 1, [0], 0x42),
{
uuid: uniqueIdBuffer(),
}
),
/can't write a gzip compressed block 0/
)
})
})

View File

@@ -3,12 +3,15 @@ import { asyncMap, asyncMapSettled } from '@xen-orchestra/async-map'
import { createLogger } from '@xen-orchestra/log'
import { stringify } from 'uuid'
import { decorateMethodsWith } from '@vates/decorate-with'
import assert from 'node:assert'
import { basename, dirname, join, resolve } from 'node:path'
import { normalize } from '@xen-orchestra/fs/path'
import { synchronized } from 'decorator-synchronized'
import Disposable from 'promise-toolbox/Disposable'
import groupBy from 'lodash/groupBy.js'
import pickBy from 'lodash/pickBy.js'
import reduce from 'lodash/reduce.js'
import { Task } from '@vates/task'
import { BACKUP_DIR } from './_getVmBackupDir.mjs'
import {
@@ -22,7 +25,12 @@ import {
import { fileRestoreDecorators, fileRestoreMethods } from './_fileRestore.mjs'
import { formatFilenameDate } from './_filenameDate.mjs'
import { isMetadataFile } from './_backupType.mjs'
import { readBackupJournal, writeBackupJournalEntries, writeBackupJournalEntry } from './_backupJournal.mjs'
import {
isKnownJournalEvent,
readBackupJournal,
writeBackupJournalEntries,
writeBackupJournalEntry,
} from './_backupJournal.mjs'
import { isValidXva } from './_isValidXva.mjs'
import { watchStreamSize } from './_watchStreamSize.mjs'
@@ -121,7 +129,7 @@ export class RemoteAdapter {
* @param {import('./_backupJournal.mjs').BackupJournalReason} [opts.reason] what triggered the
* deletion, as recorded in the journal
*/
async deleteDeltaVmBackups(backups, { reason = 'retention' } = {}) {
async deleteDeltaVmBackups(backups, { reason = 'retention', immediate = false } = {}) {
// this will delete the json, unused VHDs will be detected by `cleanVm`
await deleteDeltaVmBackupFiles(
this._handler,
@@ -129,6 +137,62 @@ export class RemoteAdapter {
)
await this.#forgetVmBackups(backups, reason)
if (immediate) {
return this.#mergeVmDirsAfterDelete(backups)
}
return new Set()
}
// group by VM backup dir so multiple disks/backups deleted for the same
// VM in one call trigger a single merge, not one per backup
async #mergeVmDirsAfterDelete(backups) {
const dirs = new Set(backups.map(({ _filename }) => dirname(_filename)))
const mergedDirs = new Set()
await Task.run(
{
properties: {
name: 'clean VM',
total: dirs.size,
},
},
async () => {
let done = 0
await asyncEach(
dirs,
async dir => {
await Task.run(
{
properties: {
name: `clean VM dir: ${dir}`,
},
},
async () => {
try {
await this.cleanVm(dir, {
remove: true,
merge: true,
logInfo: Task.info,
logWarn: Task.warning,
})
mergedDirs.add(dir)
} catch (error) {
Task.warning('failed to merge VM backup chain after immediate delete', { error, path: dir })
throw error
}
}
)
done++
Task.set('progress', Math.round((done / dirs.size) * 100))
},
{ concurrency: 2, stopOnError: false }
)
}
)
return mergedDirs
}
async deleteMetadataBackup(backupId) {
@@ -166,7 +230,7 @@ export class RemoteAdapter {
return this.deleteVmBackups([file])
}
async deleteVmBackups(files) {
async deleteVmBackups(files, { immediate = false } = {}) {
const metadataOrNull = await asyncMap(files, async file => {
try {
return await this.readVmBackupMetadata(file)
@@ -197,8 +261,10 @@ export class RemoteAdapter {
throw new Error('no deleter for backup modes: ' + unsupportedModes.join(', '))
}
const promises = []
let deltaBackupDirsPromise = Promise.resolve(new Set())
if (delta !== undefined) {
promises.push(this.deleteDeltaVmBackups(delta, { reason: 'user' }))
deltaBackupDirsPromise = this.deleteDeltaVmBackups(delta, { reason: 'user', immediate })
promises.push(deltaBackupDirsPromise)
}
if (full !== undefined) {
promises.push(this.deleteFullVmBackups(full, { reason: 'user' }))
@@ -208,12 +274,43 @@ export class RemoteAdapter {
}
await Promise.all(promises)
await asyncMap(new Set(files.map(file => dirname(file))), dir =>
// - don't merge in main process, unused VHDs will be merged in the next backup run
// - don't error in case this fails:
// - if lock is already being held, a backup is running and cleanVm will be ran at the end
// - otherwise, there is nothing more we can do, orphan file will be cleaned in the future
this.cleanVm(dir, { remove: true, logWarn: warn }).catch(noop)
const deltaBackupDirs = await deltaBackupDirsPromise
const otherBackupDirs = new Set(files.map(file => dirname(file)).filter(dir => !deltaBackupDirs.has(dir)))
await Task.run(
{
properties: {
name: 'clean VM of non-delta backups(full backups, ...) dirs',
total: otherBackupDirs.size,
},
},
async () => {
let processed = 0
await asyncEach(
otherBackupDirs,
async dir => {
await Task.run(
{
properties: {
name: `clean VM dir: ${dir}`,
},
},
async () => {
// - don't merge in main process, unused VHDs will be merged in the next backup run
try {
await this.cleanVm(dir, { remove: true, logWarn: warn })
} catch (error) {
Task.warning('failed to remove VM backup', { error, path: dir })
throw error
}
}
)
processed++
Task.set('progress', Math.round((processed / otherBackupDirs.size) * 100))
},
{ concurrency: 2, stopOnError: false }
)
}
)
}
@@ -221,6 +318,14 @@ export class RemoteAdapter {
return this.handler.getConfig('useVhdDirectory')
}
/**
* @returns {string | undefined} the compression of the VHD directories written on this remote ('none' if not
* compressed), undefined if this remote writes plain VHD files
*/
getVhdDirectoryCompression() {
return this.useVhdDirectory() ? (this.handler.getConfig('compressionType') ?? 'brotli') : undefined // compatibility layer
}
#useAlias() {
return this.useVhdDirectory()
}
@@ -253,9 +358,13 @@ export class RemoteAdapter {
return vmsUuids
}
/**
* @returns {Promise<Record<string, import('./formatVmBackups.mjs').VmBackupMetadata[]>>} keyed by VM UUID,
* without the VMs which have no backups
*/
async listAllVmBackups() {
const vmsUuids = await this.listAllVms()
const backups = { __proto__: null }
const backups = Object.create(null)
await asyncEach(vmsUuids, async vmUuid => {
const vmBackups = await this.listVmBackups(vmUuid)
if (vmBackups.length !== 0) {
@@ -435,14 +544,99 @@ export class RemoteAdapter {
return backups.sort(compareTimestamp)
}
// read the backup events which happened on this remote after `since` (timestamp in ms),
// oldest first
// read the backup events which happened on this remote after `cursor`, oldest first
/**
* @param {number} [since] timestamp in ms, exclusive
* @param {string} [cursor] path of the last entry already read, exclusive
* @param {object} [opts]
* @param {boolean} [opts.mustExist] whether a missing journal directory should throw
* @returns {Promise<import('./_backupJournal.mjs').BackupJournalEntry[]>}
*/
async readBackupJournal(since) {
return readBackupJournal(this._handler, since)
async readBackupJournal(cursor, opts) {
return readBackupJournal(this._handler, cursor, opts)
}
// Same as `readBackupJournal()`, with the current metadata of the added and changed backups
// attached, so that a listing can be brought up to date from the result alone, in a single
// round-trip for a caller which is not on this host.
//
// The metadata is read back from the repository instead of being carried by the journal, so that
// the result always reflects the current content of the file, e.g. the size a merge updated.
/**
* @param {string} [cursor] path of the last entry already read, exclusive
* @param {object} [opts]
* @param {boolean} [opts.mustExist] whether a missing journal directory should throw
* @returns {Promise<{
* events: import('./formatVmBackups.mjs').ResolvedJournalEvent[]
* cursor: string | undefined
* }>} the cursor to pass on the next call: unchanged when nothing new was read
*/
async readBackupJournalEvents(cursor, opts) {
const entries = await this.readBackupJournal(cursor, opts)
// the entries are oldest first, therefore the last event of a backup is its current state: a
// backup which was written then deleted costs no metadata read at all, and one which was
// rewritten several times costs a single one
const lastEventByFilename = new Map()
entries.forEach(({ event, filename, vmUuid }, index) => {
if (!isKnownJournalEvent(event)) {
warn('ignoring unsupported journal event', { event, filename })
return
}
// the entries are written by several code paths which don't agree on the leading slash
lastEventByFilename.set(normalize(filename), { event, index, vmUuid })
})
// the index, in `entries`, of the earliest one whose metadata could not be read: the cursor is
// clamped to just before it, so that the next call retries it instead of skipping it
let minFailedIndex = entries.length
// there is at most one event per backup left, therefore they can be resolved concurrently and
// the order `asyncEach` returns them in does not matter
const events = []
await asyncEach(lastEventByFilename, async ([filename, { event, index, vmUuid }]) => {
if (event === 'del') {
events.push({ event, vmUuid, filename })
return
}
let metadata
try {
metadata = await this.readVmBackupMetadata(filename)
} catch (error) {
if (error.code === 'ENOENT') {
// the metadata is gone while its last event says it should be there: it was deleted
// without being journaled, e.g. by a user or a third party tool directly on the
// repository. Report it as a deletion instead of waiting for the next full rebuild.
debug('reporting a backup whose metadata is missing as deleted', { event, filename })
events.push({ event: 'del', vmUuid, filename })
return
}
// One unreadable metadata must not fail the whole read: the caller would forget the
// repository and list it in full on every call for as long as the file stays unreadable,
// which is much more expensive than what this read costs.
//
// Its event is kept behind the cursor instead of being dropped, so that the next read
// tries it again: a transient failure costs nothing, and a permanent one only widens the
// window of a journal read, until the caller rebuilds from scratch anyway.
warn(`can't read the metadata of a backup an event is about`, { error, event, filename })
if (index < minFailedIndex) {
minFailedIndex = index
}
return
}
events.push({ event, vmUuid, filename, metadata })
})
// advance the cursor up to the last successfully read entry, oldest first: unchanged when
// nothing was read at all, or when the very first entry already failed
if (minFailedIndex > 0) {
cursor = entries[minFailedIndex - 1]._filename
}
return { events, cursor }
}
async writeVmBackupMetadata(vmUuid, metadata) {
@@ -491,7 +685,7 @@ export class RemoteAdapter {
path,
concurrency: writeBlockConcurrency,
validator,
compression: handler.getConfig('compressionType') ?? 'brotli', // compatibility layer
compression: this.getVhdDirectoryCompression(),
uuid,
parentUuid,
parentPath,
@@ -517,7 +711,8 @@ export class RemoteAdapter {
) {
const container = watchStreamSize(input)
await this._handler.outputStream(path, input, {
checksum,
// a known checksum is the one of unencrypted data: an encrypted remote stores no checksum
checksum: typeof checksum === 'string' && this._handler.isEncrypted ? false : checksum,
dirMode: this._dirMode,
maxStreamLength,
streamLength,
@@ -530,19 +725,24 @@ export class RemoteAdapter {
}
// open the hierarchy of ancestors until we find a full one
async _createVhdDisk(handler, path, { useChain }) {
async _createVhdDisk(handler, path, { useChain, rawBlocksCompression }) {
let disk
if (useChain) {
assert.strictEqual(rawBlocksCompression, undefined, `can't read raw blocks of a disk chain`)
disk = await openDiskChain({ handler, path })
} else {
disk = new RemoteVhdDisk({ handler, path })
disk = new RemoteVhdDisk({ handler, path, rawBlocksCompression })
await disk.init()
}
disk = new ReadAhead(disk)
return disk
}
async readIncrementalVmBackup(metadata, ignoredVdis, { useChain = true } = {}) {
/**
* `rawBlocksCompression`: the blocks of the VHD directories using this compression are read as stored (see
* RemoteVhdDisk), to be written as is in a remote using the same compression
*/
async readIncrementalVmBackup(metadata, ignoredVdis, { useChain = true, rawBlocksCompression } = {}) {
const handler = this._handler
const { vbds, vhds, vifs, vm, vmSnapshot, vtpms } = metadata
const dir = dirname(metadata._filename)
@@ -550,7 +750,7 @@ export class RemoteAdapter {
const disks = {}
await asyncMapSettled(Object.keys(vdis), async ref => {
delete vdis[ref].baseVdi
disks[ref] = await this._createVhdDisk(handler, join(dir, vhds[ref]), { useChain })
disks[ref] = await this._createVhdDisk(handler, join(dir, vhds[ref]), { useChain, rawBlocksCompression })
})
return {
@@ -568,6 +768,28 @@ export class RemoteAdapter {
return this._handler.createReadStream(resolve('/', dirname(metadata._filename), metadata.xva))
}
/**
* @returns {Promise<string | undefined>} the checksum of the XVA as stored, undefined if it is unknown (no checksum
* file, or encrypted remote: the stored data are not the data read)
*/
async readFullVmBackupChecksum(metadata) {
if (this._handler.isEncrypted) {
return
}
try {
const checksum = String(
await this._handler.readFile(resolve('/', dirname(metadata._filename), metadata.xva) + '.checksum')
).trim()
// $<algorithm id>$<salt>$<hash>
return checksum.startsWith('$') ? checksum : undefined
} catch (error) {
if (error.code === 'ENOENT') {
return
}
throw error
}
}
async readVmBackupMetadata(path) {
let json
let isImmutable = false

View File

@@ -12,7 +12,7 @@ import { VHDFOOTER, VHDHEADER } from './tests.fixtures.mjs'
import { VhdFile, Constants } from 'vhd-lib'
import { rimraf } from 'rimraf'
import { BACKUP_JOURNAL_DIR, formatJournalDay, formatJournalTime } from './_backupJournal.mjs'
import { BACKUP_JOURNAL_DIR, formatJournalDay, formatJournalTime, journalCursorAt } from './_backupJournal.mjs'
import { formatFilenameDate } from './_filenameDate.mjs'
const { beforeEach, afterEach, describe } = test
@@ -65,13 +65,13 @@ function writeJournalEntryAt(timestamp, entry) {
)
}
// journal entries are stamped with the writer's clock, therefore a watermark used in an assertion
// must be strictly between the entries written before it and the ones written after it
// journal entries are stamped with the writer's clock, therefore a cursor used in an assertion must
// be strictly between the entries written before it and the ones written after it
async function mark() {
await sleep(5)
const since = Date.now()
const cursor = journalCursorAt(Date.now())
await sleep(5)
return since
return cursor
}
async function generateVhd(path, opts = {}) {
@@ -128,7 +128,7 @@ describe('backup journal', { concurrency: 1 }, () => {
test('writeVmBackupMetadata() records an `add` event', async () => {
const path = await writeFullBackup()
const entries = await adapter.readBackupJournal(0)
const entries = await adapter.readBackupJournal()
assert.equal(entries.length, 1)
const { date, timestamp, _filename, ...entry } = entries[0]
@@ -147,7 +147,7 @@ describe('backup journal', { concurrency: 1 }, () => {
const path = await writeFullBackup()
const [entryPath] = await listJournal()
const { timestamp } = (await adapter.readBackupJournal(0))[0]
const { timestamp } = (await adapter.readBackupJournal())[0]
const name = entryPath.split('/').pop()
assert.equal(entryPath, `/${BACKUP_JOURNAL_DIR}/${formatJournalDay(timestamp)}/${name}`)
@@ -159,10 +159,10 @@ describe('backup journal', { concurrency: 1 }, () => {
const full = await writeFullBackup(Date.now() - 2000)
const delta = await writeDeltaBackup(Date.now() - 1000)
const since = await mark()
const cursor = await mark()
await adapter.deleteVmBackups([full, delta])
const entries = await adapter.readBackupJournal(since)
const entries = await adapter.readBackupJournal(cursor)
assert.deepEqual(
entries
.map(({ event, filename, vmUuid, reason, who }) => ({ event, filename, vmUuid, reason, who }))
@@ -178,10 +178,10 @@ describe('backup journal', { concurrency: 1 }, () => {
await writeDeltaBackup()
const [backup] = await adapter.listVmBackups(vmUuid)
const since = await mark()
const cursor = await mark()
await adapter.deleteDeltaVmBackups([backup])
const entries = await adapter.readBackupJournal(since)
const entries = await adapter.readBackupJournal(cursor)
assert.equal(entries.length, 1)
assert.equal(entries[0].event, 'del')
assert.equal(entries[0].reason, 'retention')
@@ -191,10 +191,10 @@ describe('backup journal', { concurrency: 1 }, () => {
const path = await writeFullBackup()
await handler.unlink(path)
const since = await mark()
const cursor = await mark()
await adapter.deleteVmBackup(path)
const entries = await adapter.readBackupJournal(since)
const entries = await adapter.readBackupJournal(cursor)
assert.equal(entries.length, 1)
assert.equal(entries[0].event, 'del')
assert.equal(entries[0].filename, path)
@@ -220,7 +220,7 @@ describe('backup journal', { concurrency: 1 }, () => {
JSON.stringify({ mode: 'delta', vhds: { vdi: `${relativePath}/child.vhd` }, vdis: { vdi: {} }, size: 1 })
)
const since = await mark()
const cursor = await mark()
const result = await adapter.cleanVm(rootPath, {
remove: true,
merge: true,
@@ -230,7 +230,7 @@ describe('backup journal', { concurrency: 1 }, () => {
})
assert.ok(result.size > 0, 'nothing was merged')
const entries = await adapter.readBackupJournal(since)
const entries = await adapter.readBackupJournal(cursor)
assert.deepEqual(
entries.map(({ event, filename, vmUuid, reason }) => ({ event, filename, vmUuid, reason })).sort(byFilename),
[
@@ -246,10 +246,10 @@ describe('backup journal', { concurrency: 1 }, () => {
JSON.stringify({ mode: 'delta', vhds: { vdi: `${relativePath}/gone.vhd` }, vdis: { vdi: {} } })
)
const since = await mark()
const cursor = await mark()
await adapter.cleanVm(rootPath, { remove: false, logInfo: noop, logWarn: noop, lock: false })
assert.deepEqual(await adapter.readBackupJournal(since), [])
assert.deepEqual(await adapter.readBackupJournal(cursor), [])
})
test('a failure to journal does not fail the operation it journals', async () => {
@@ -274,22 +274,38 @@ describe('readBackupJournal()', { concurrency: 1 }, () => {
const DAY = 24 * 60 * 60 * 1e3
test('returns nothing on a repository without a journal', async () => {
assert.deepEqual(await adapter.readBackupJournal(0), [])
assert.deepEqual(await adapter.readBackupJournal(), [])
})
test('replays the days since `since`, oldest first', async () => {
test('a missing journal directory is not an error unless mustExist is set', async () => {
const cursor = journalCursorAt(Date.now())
assert.deepEqual(await adapter.readBackupJournal(cursor), [])
await assert.rejects(adapter.readBackupJournal(cursor, { mustExist: true }), { code: 'ENOENT' })
})
test('a directory removed after an entry was read from it throws when mustExist is set', async () => {
await writeFullBackup()
const [cursor] = await listJournal()
await rimraf(`${tempDir}/${BACKUP_JOURNAL_DIR}`)
assert.deepEqual(await adapter.readBackupJournal(cursor), [])
await assert.rejects(adapter.readBackupJournal(cursor, { mustExist: true }), { code: 'ENOENT' })
})
test('replays the days since the cursor, oldest first', async () => {
const old = `/${rootPath}/old.json`
const twoDaysAgo = Date.now() - 2 * DAY
await writeJournalEntryAt(twoDaysAgo, { event: 'add', vmUuid, filename: old, reason: 'backup' })
const recent = await writeFullBackup()
assert.deepEqual(
(await adapter.readBackupJournal(twoDaysAgo - 1000)).map(_ => _.filename),
(await adapter.readBackupJournal(journalCursorAt(twoDaysAgo - 1000))).map(_ => _.filename),
[old, recent]
)
})
test('does not read the days older than `since`', async () => {
test('does not read the days older than the cursor', async () => {
await writeJournalEntryAt(Date.now() - 2 * DAY, {
event: 'add',
vmUuid,
@@ -305,20 +321,20 @@ describe('readBackupJournal()', { concurrency: 1 }, () => {
return list(dir, ...rest)
}
const since = Date.now() - 60e3
const bound = Date.now() - 60e3
assert.deepEqual(
(await adapter.readBackupJournal(since)).map(_ => _.filename),
(await adapter.readBackupJournal(journalCursorAt(bound))).map(_ => _.filename),
[recent]
)
// only the journal root and today's directory, whatever the number of days the journal holds
assert.deepEqual(listed, [`/${BACKUP_JOURNAL_DIR}`, `/${BACKUP_JOURNAL_DIR}/${formatJournalDay(since)}`])
assert.deepEqual(listed, [`/${BACKUP_JOURNAL_DIR}`, `/${BACKUP_JOURNAL_DIR}/${formatJournalDay(bound)}`])
})
test('`since` only bounds the time of its own day, not of the days after it', async () => {
// regression guard: entries are named after their time only, so the time of `since` must be
// compared with the entries of its own day and with no other, otherwise an entry of an early
// hour of a later day looks older than a `since` of a late hour
test('the cursor only bounds the time of its own day, not of the days after it', async () => {
// regression guard: entries are named after their time only, so the day+time of the cursor must
// be compared with the entries of its own day and with no other, otherwise an entry of an early
// hour of a later day looks older than a cursor of a late hour
const midnight = Math.floor(Date.now() / DAY) * DAY
const lateYesterday = `/${rootPath}/late-yesterday.json`
const earlyToday = `/${rootPath}/early-today.json`
@@ -326,31 +342,31 @@ describe('readBackupJournal()', { concurrency: 1 }, () => {
await writeJournalEntryAt(midnight + 30 * 60e3, { event: 'add', vmUuid, filename: earlyToday })
assert.deepEqual(
(await adapter.readBackupJournal(midnight - 60 * 60e3)).map(_ => _.filename),
(await adapter.readBackupJournal(journalCursorAt(midnight - 60 * 60e3))).map(_ => _.filename),
[lateYesterday, earlyToday]
)
// 23:45 of yesterday: its own day is bounded, today is not
assert.deepEqual(
(await adapter.readBackupJournal(midnight - 15 * 60e3)).map(_ => _.filename),
(await adapter.readBackupJournal(journalCursorAt(midnight - 15 * 60e3))).map(_ => _.filename),
[earlyToday]
)
})
test('returns the entries stamped after `since`, oldest first', async () => {
test('returns the entries after the cursor, oldest first', async () => {
const first = await writeFullBackup(Date.now() - 2000)
const since = await mark()
const cursor = await mark()
const second = await writeDeltaBackup(Date.now() - 1000)
assert.deepEqual(
(await adapter.readBackupJournal(0)).map(_ => _.filename),
(await adapter.readBackupJournal()).map(_ => _.filename),
[first, second]
)
assert.deepEqual(
(await adapter.readBackupJournal(since)).map(_ => _.filename),
(await adapter.readBackupJournal(cursor)).map(_ => _.filename),
[second]
)
assert.deepEqual(await adapter.readBackupJournal(Date.now() + 1000), [])
assert.deepEqual(await adapter.readBackupJournal(journalCursorAt(Date.now() + 1000)), [])
})
test('skips truncated entries and unrecognized filenames without hiding their siblings', async () => {
@@ -368,7 +384,7 @@ describe('readBackupJournal()', { concurrency: 1 }, () => {
// a stray file where only day directories are expected
await handler.writeFile(`/${BACKUP_JOURNAL_DIR}/README`, 'hello')
const entries = await adapter.readBackupJournal(0)
const entries = await adapter.readBackupJournal()
assert.deepEqual(
entries.map(_ => _.filename),
[path]
@@ -377,6 +393,146 @@ describe('readBackupJournal()', { concurrency: 1 }, () => {
})
})
describe('readBackupJournalEvents()', { concurrency: 1 }, () => {
// records the paths whose metadata is read from now on, to check a replay does not re-read the
// same backup once per event
function watchMetadataReads() {
const read = adapter.readVmBackupMetadata.bind(adapter)
const paths = []
adapter.readVmBackupMetadata = path => {
paths.push(path)
return read(path)
}
return paths
}
test('reads the metadata of a backup once, whatever the number of events on it', async () => {
const path = await writeFullBackup()
const now = Date.now()
await writeJournalEntryAt(now + 1000, { event: 'change', vmUuid, filename: path, reason: 'merge' })
await writeJournalEntryAt(now + 2000, { event: 'change', vmUuid, filename: path, reason: 'merge' })
const reads = watchMetadataReads()
const { events } = await adapter.readBackupJournalEvents()
assert.deepEqual(reads, [path])
// the last event of a backup is its current state
assert.equal(events.length, 1)
assert.equal(events[0].event, 'change')
assert.equal(events[0].filename, path)
assert.equal(events[0].metadata._filename, path)
})
test('costs no metadata read at all for a backup which was added then deleted', async () => {
const path = await writeFullBackup()
await adapter.deleteVmBackup(path)
const reads = watchMetadataReads()
const { events } = await adapter.readBackupJournalEvents()
assert.deepEqual(reads, [])
assert.deepEqual(
events.filter(_ => _.filename === path),
[{ event: 'del', vmUuid, filename: path }]
)
})
test('reports a backup whose metadata is gone as deleted', async () => {
// deleted directly on the repository by a user or a third party tool, therefore not journaled
const path = await writeFullBackup()
await handler.unlink(path)
const { events } = await adapter.readBackupJournalEvents()
assert.deepEqual(events, [{ event: 'del', vmUuid, filename: path }])
})
test('names the backups the way a listing does, whatever the leading slash of the entry', async () => {
const path = await writeFullBackup()
await writeJournalEntryAt(Date.now() + 1000, {
event: 'change',
vmUuid,
filename: path.slice(1),
reason: 'merge',
})
const { events } = await adapter.readBackupJournalEvents()
// both entries are about the same backup, therefore they must reduce to a single event
assert.equal(events.length, 1)
assert.equal(events[0].filename, path)
})
test('ignores the event kinds it does not know', async () => {
const path = await writeFullBackup()
await writeJournalEntryAt(Date.now() + 1000, {
event: 'a-future-event',
vmUuid,
filename: `/${rootPath}/other.json`,
})
const { events } = await adapter.readBackupJournalEvents()
assert.deepEqual(
events.map(_ => _.filename),
[path]
)
})
test('keeps an unreadable metadata behind the cursor instead of failing the whole read', async () => {
const readable = await writeFullBackup(Date.now() - 2000)
const unreadable = await writeDeltaBackup(Date.now() - 1000)
const readFile = handler.readFile.bind(handler)
handler.readFile = async (path, ...rest) => {
if (path === unreadable) {
throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' })
}
return readFile(path, ...rest)
}
const { events, cursor } = await adapter.readBackupJournalEvents()
// the backups which could be read are still brought up to date
assert.deepEqual(
events.map(_ => _.filename),
[readable]
)
// and the one which could not is read again on the next call
handler.readFile = readFile
const { events: retried } = await adapter.readBackupJournalEvents(cursor)
assert.deepEqual(
retried.map(_ => _.filename),
[unreadable]
)
})
test('returns the cursor to start from on the next call', async () => {
await writeFullBackup(Date.now() - 2000)
const { events: first, cursor } = await adapter.readBackupJournalEvents()
assert.equal(first.length, 1)
await sleep(5)
const path = await writeDeltaBackup()
const { events } = await adapter.readBackupJournalEvents(cursor)
assert.deepEqual(
events.map(_ => _.filename),
[path]
)
})
test('keeps the same cursor when nothing new was read', async () => {
await writeFullBackup(Date.now() - 2000)
const { cursor } = await adapter.readBackupJournalEvents()
const { events, cursor: next } = await adapter.readBackupJournalEvents(cursor)
assert.deepEqual(events, [])
assert.equal(next, cursor)
})
})
describe('backup journal on an encrypted remote', { concurrency: 1 }, () => {
test('entries are readable without the encryption key', async () => {
const encryptedDir = await pFromCallback(cb => tmp.dir(cb))

View File

@@ -1,11 +1,15 @@
// @ts-check
import { asyncEach } from '@vates/async-each'
import { basename, normalize } from '@xen-orchestra/fs/path'
import { basename, dirname, normalize } from '@xen-orchestra/fs/path'
import { createLogger } from '@xen-orchestra/log'
import { randomBytes } from 'node:crypto'
import { utcFormat, utcParse } from 'd3-time-format'
const { debug, warn } = createLogger('xo:backups:backupJournal')
const BACKUP_JOURNAL_EVENTS = /** @type {const} */ (['add', 'change', 'del'])
/**
* A remote handler, plus the two unencrypted read/write primitives this module relies on to keep
* the journal readable without the encryption key. They are not part of the handler's declared
@@ -24,7 +28,7 @@ const { debug, warn } = createLogger('xo:backups:backupJournal')
* - `change`: the metadata was rewritten in place, the backup itself still exists
* - `del`: the backup was removed
*
* @typedef {'add'|'change'|'del'} BackupJournalEvent
* @typedef {(typeof BACKUP_JOURNAL_EVENTS)[number]} BackupJournalEvent
*/
/**
@@ -78,6 +82,17 @@ const { debug, warn } = createLogger('xo:backups:backupJournal')
// (Object Lock) repositories, where `cache.json.gz` cannot be rewritten.
export const BACKUP_JOURNAL_DIR = 'xo-backup-log'
/**
* Whether this version knows what to do with an event.
*
* A journal can hold entries written by a more recent version, and a proxy can be more recent than
* the xo-server it answers to: readers drop the events this rejects instead of guessing.
*
* @param {unknown} event
* @returns {event is BackupJournalEvent}
*/
export const isKnownJournalEvent = event => BACKUP_JOURNAL_EVENTS.includes(/** @type {any} */ (event))
// Same idea as `formatFilenameDate` but with milliseconds, so that two events on the same VM
// within the same second keep distinct, ordered names.
const formatDate = utcFormat('%Y%m%dT%H%M%S.%LZ')
@@ -114,6 +129,20 @@ export const formatJournalDay = timestamp => dayOfDate(formatJournalDate(timesta
*/
export const formatJournalTime = timestamp => formatTime(new Date(timestamp))
/**
* Builds a synthetic cursor for `readBackupJournal()`, pointing just before any entry which could
* have been written at `timestamp` or later.
*
* Only meant to bootstrap the cursor of a reader which has never read the journal before: every
* cursor after that should be the `_filename` of an entry actually read, which sorts correctly
* against other entries regardless of clock skew, unlike this one.
*
* @param {number} timestamp in ms
* @returns {string}
*/
export const journalCursorAt = timestamp =>
`/${BACKUP_JOURNAL_DIR}/${formatJournalDay(timestamp)}/${formatJournalTime(timestamp)}`
/**
* Reads back the date of a journal entry, from its day and the time part of its name.
*
@@ -210,37 +239,47 @@ export async function writeBackupJournalEntries(handler, entries, opts) {
}
/**
* Reads the journal entries stamped after `since`, oldest first.
* Reads the journal entries stamped after `cursor`, oldest first.
*
* Corrupt or partially written entries are skipped, they must not hide their siblings. Computing the
* watermark to pass as `since` on the next call is the caller's responsibility.
* Corrupt or partially written entries are skipped, they must not hide their siblings. The `_filename`
* of the last entry returned is the cursor to pass on the next call (or `journalCursorAt()` to
* bootstrap the first one): comparing full entry names is immune to clock skew between the process
* which writes the journal and the one reading it, unlike comparing timestamps.
*
* `since` is exclusive: entries stamped exactly at `since` are not returned.
* `cursor` is exclusive: the entry it points to, if any, is not returned again.
*
* A missing journal directory is not an error unless `mustExist` is set: a repository which was never
* written to (or a bootstrap cursor guessed from the clock instead of an entry actually read) has no
* directory to find, which is benign. `mustExist` is for a caller which has already read real entries
* from that directory before, for which a disappearance is anomalous and worth failing loudly on
* instead of being silently treated as "no new entries".
*
* @param {RemoteHandler} handler
* @param {number} [since] timestamp in ms
* @param {string} [cursor] path to bound the read; entries at or before it are not returned
* @param {object} [opts]
* @param {boolean} [opts.mustExist] whether a missing journal directory should throw
* @returns {Promise<BackupJournalEntry[]>} oldest first
*/
export async function readBackupJournal(handler, since = 0) {
const minDay = formatJournalDay(since)
const minTime = formatJournalTime(since)
export async function readBackupJournal(handler, cursor, { mustExist = false } = {}) {
const minDay = cursor === undefined ? '' : basename(dirname(cursor))
const minName = cursor === undefined ? '' : basename(cursor)
// only the days which can hold entries newer than `since` are listed, therefore the cost of a read
// only the days which can hold entries newer than `cursor` are listed, therefore the cost of a read
// does not grow with the whole history of the repository
const days = (
await handler.list(`/${BACKUP_JOURNAL_DIR}`, {
filter: name => isDayName(name) && name >= minDay,
ignoreMissing: true,
ignoreMissing: !mustExist,
})
).sort()
/** @type {Array<{ day: string, path: string }>} */
const found = []
for (const day of days) {
// entries are named after their time only: `since` bounds the day it falls into, every later
// entries are named after their time only: `cursor` bounds the day it falls into, every later
// day is taken whole
const names = await handler.list(`/${BACKUP_JOURNAL_DIR}/${day}`, {
filter: day === minDay ? name => name.slice(0, TIME_LENGTH) > minTime : undefined,
filter: day === minDay ? name => name > minName : undefined,
ignoreMissing: true,
})
for (const name of names) {

View File

@@ -75,7 +75,6 @@ class BackupWorker {
yield new RemoteAdapter(handler, {
debounceResource: this.debounceResource,
dirMode: this.#config.dirMode,
vhdDirectoryCompression: this.#config.vhdDirectoryCompression,
})
} finally {
await handler.forget()

View File

@@ -135,7 +135,12 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
$defer,
incrementalVm,
sr,
{ cancelToken = CancelToken.none, newMacAddresses = false, targetRef = undefined } = {}
{
cancelToken = CancelToken.none,
newMacAddresses = false,
targetRef = undefined,
vmNamePrefix = '[Importing…] ',
} = {}
) {
const { version } = incrementalVm
if (compareVersions(version, '1.0.0') < 0) {
@@ -224,7 +229,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
},
ha_always_run: false,
is_a_template: false,
name_label: '[Importing…] ' + vmRecord.name_label,
name_label: vmNamePrefix + vmRecord.name_label,
},
{
bios_strings: vmRecord.bios_strings,
@@ -255,7 +260,12 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
const vdi = vdiRecords[vdiRef]
let newVdi
if (vdi.baseVdi?.$ref !== undefined) {
const isLiveMounted = vdi.liveMountedVdiRef !== undefined
if (isLiveMounted) {
// the disk is served by a live mount: the VDI already exists and only has to be attached.
// No `$defer.onFailure` destroy here, it belongs to the mount and not to this import.
newVdi = await xapi.getRecord('VDI', vdi.liveMountedVdiRef)
} else if (vdi.baseVdi?.$ref !== undefined) {
if (isUpdate) {
// In update mode, reuse the existing target VDI directly — no clone needed.
newVdi = vdi.baseVdi
@@ -284,6 +294,9 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
await asyncMap(Object.values(vdiVbds), vbd =>
xapi.VBD_create({
...vbd,
// an uncached live mount serves its disk read only: attach it as such, instead of letting
// the guest discover it through I/O errors on its first write
mode: isLiveMounted && vdi.liveMountedReadOnly ? 'RO' : vbd.mode,
VDI: newVdi.$ref,
VM: vmRef,
})
@@ -294,9 +307,16 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
})
// 3.5. Destroy old VDIs that are no longer attached to the VM.
// Uses ignoreErrors because some storage backends refuse to destroy a VDI that still has snapshot children;
// those VDIs will become truly orphaned once the old snapshot VMs are cleaned up by _deleteOldEntries.
await asyncMap([...oldVdiRefs], ref => ignoreErrors.call(xapi.call('VDI.destroy', ref)))
// Some storage backends refuse to destroy a VDI that still has snapshot children.
// Log it and leave it for later cleanup.
await asyncMap([...oldVdiRefs], ref =>
xapi.call('VDI.destroy', ref).catch(error =>
Task.warning('failed to destroy orphaned VDI', {
vdi: xapi.getObject(ref, undefined)?.uuid ?? ref,
error,
})
)
)
// 4. For updates, destroy existing VIFs before recreating them.
if (isUpdate) {

View File

@@ -111,93 +111,97 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
const preTakenTimestampByVmId = {}
const failedSnapshotByVmId = {}
if (settings.synchronizedSnapshot) {
await Task.run({ properties: { name: 'snapshot VMs' } }, async () => {
await Disposable.use(
Disposable.all(vmIds.map(vmId => this._getRecord('VM', vmId).catch(noop))),
async vms => {
// remove vms that failed (already handled)
vms = vms.filter(_ => _ !== undefined)
const batchIds = selectSynchronizedSnapshotVms(settings.synchronizedSnapshot, vms)
await asyncEach(
[...vms].filter(vm => batchIds.has(vm.uuid)),
async vm => {
const vmSettings = { ...settings, ...allSettings[vm.uuid] }
const opts = {
baseSettings,
config,
getSnapshotNameLabel,
healthCheckSr,
job,
remoteAdapters,
schedule,
settings: vmSettings,
srs,
throttleGenerator,
throttleStream,
vm,
}
let vmBackup
try {
vmBackup = this._getVmBackup(job.mode, opts)
if (
!vmBackup._settings.offlineBackup &&
!vmBackup._settings.offlineSnapshot &&
(await vmBackup._mustDoSnapshot())
) {
await vmBackup._prepareAndSnapshot()
preTakenTimestampByVmId[vm.uuid] = vmBackup.timestamp
}
} catch (error) {
failedSnapshotByVmId[vm.uuid] = error
if (vmBackup !== undefined) {
try {
await vmBackup._fetchJobSnapshots()
await vmBackup._removeUnusedSnapshots()
await vmBackup._cleanMetadata()
} catch (cleanupError) {
// Best effort cleanup
}
}
}
},
{
concurrency: settings.snapshotConcurrency,
}
)
}
)
})
}
const snapshotedVmIds = new Set(vmIds.filter(id => !(id in failedSnapshotByVmId)))
Object.entries(failedSnapshotByVmId).forEach(([vmId, error]) => {
Task.run(
{
properties: { id: vmId, name: 'backup VM', type: 'VM' },
},
() => Promise.reject(error)
).catch(noop)
})
const queue = new Set(snapshotedVmIds)
const taskByVmId = {}
const nTriesByVmId = {}
const handleVm = vmUuid => {
const getVmTask = () => {
const started = taskByVmId[vmUuid] !== undefined
if (!started) {
taskByVmId[vmUuid] = new Task(taskStart)
}
return {
task: taskByVmId[vmUuid],
started,
}
const getVmTask = (vmUuid, name_label) => {
const started = taskByVmId[vmUuid] !== undefined
if (!started) {
taskByVmId[vmUuid] = new Task({ properties: { id: vmUuid, name: 'backup VM', type: 'VM', name_label } })
}
return {
task: taskByVmId[vmUuid],
started,
}
}
if (settings.synchronizedSnapshot) {
await Disposable.use(
Disposable.all(vmIds.map(vmId => this._getRecord('VM', vmId).catch(noop))),
async vms => {
// remove vms that failed (already handled)
vms = vms.filter(_ => _ !== undefined)
const batchIds = selectSynchronizedSnapshotVms(settings.synchronizedSnapshot, vms)
if (batchIds.size > 0) {
Task.info('synchronized snapshot', { vms: Array.from(batchIds) })
}
await asyncEach(
[...vms].filter(vm => batchIds.has(vm.uuid)),
async vm => {
const { task } = getVmTask(vm.uuid, vm.name_label)
const vmSettings = { ...settings, ...allSettings[vm.uuid] }
const opts = {
baseSettings,
config,
getSnapshotNameLabel,
healthCheckSr,
job,
remoteAdapters,
schedule,
settings: vmSettings,
srs,
throttleGenerator,
throttleStream,
vm,
}
await task
.runInside(async () => {
let vmBackup
try {
vmBackup = this._getVmBackup(job.mode, opts)
if (
!vmBackup._settings.offlineBackup &&
!vmBackup._settings.offlineSnapshot &&
(await vmBackup._mustDoSnapshot())
) {
await vmBackup._prepareAndSnapshot()
preTakenTimestampByVmId[vm.uuid] = vmBackup.timestamp
}
} catch (error) {
failedSnapshotByVmId[vm.uuid] = error
if (vmBackup !== undefined) {
try {
await vmBackup._fetchJobSnapshots()
await vmBackup._removeUnusedSnapshots()
await vmBackup._cleanMetadata()
} catch (cleanupError) {
// Best effort cleanup
}
}
throw error
}
})
.catch(noop)
},
{
concurrency: settings.snapshotConcurrency,
}
)
}
)
}
const snapshotedVmIds = new Set(vmIds.filter(id => !(id in failedSnapshotByVmId)))
const queue = new Set(snapshotedVmIds)
const handleVm = vmUuid => {
const vmBackupFailed = async (error, task) => {
if (isLastRun) {
return task.failure(error)
@@ -218,17 +222,13 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
nTriesByVmId[vmUuid]++
const vmSettings = { ...settings, ...allSettings[vmUuid] }
const taskStart = { properties: { id: vmUuid, name: 'backup VM', type: 'VM' } }
const isLastRun = nTriesByVmId[vmUuid] === vmSettings.nRetriesVmBackupFailures + 1
return this._getRecord('VM', vmUuid).then(
disposableVm =>
Disposable.use(disposableVm, async vm => {
if (taskStart.properties.name_label === undefined) {
taskStart.properties.name_label = vm.name_label
}
const { task } = getVmTask(vmUuid, vm.name_label)
const { task } = getVmTask()
// error has to be caught in the task to prevent its failure, but handled outside the task to execute another task.run()
let taskError
return task
@@ -268,7 +268,7 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
.catch(noop) // errors are handled by logs
}),
error => {
const { task: vmTask, started } = getVmTask()
const { task: vmTask, started } = getVmTask(vmUuid)
if (!started) {
// the task is not started (except if it's a retry), and an unstarted task can't be failed
vmTask.start()

View File

@@ -8,6 +8,10 @@ export function selectSynchronizedSnapshotVms(synchronizedSnapshot, vms) {
let matches
if (typeof synchronizedSnapshot === 'string') {
matches = vms.filter(vm => vm.tags.includes(synchronizedSnapshot))
if (matches.length === 0) {
Task.warning('no vm matched the synchronizedSnapshot tag', { synchronizedSnapshot })
}
} else if (synchronizedSnapshot === true) {
matches = vms
} else {

View File

@@ -1,3 +1,5 @@
import { createLogger } from '@xen-orchestra/log'
import { AbstractRemote } from './_AbstractRemote.mjs'
import { FullRemoteWriter } from '../_writers/FullRemoteWriter.mjs'
import { forkStreamUnpipe } from '../_forkStreamUnpipe.mjs'
@@ -5,6 +7,8 @@ import { watchStreamSize } from '../../_watchStreamSize.mjs'
import { AggregatedFullRemoteWriter } from '../_writers/AggregatedFullRemoteWriter.mjs'
import { Task } from '@vates/task'
const { warn } = createLogger('xo:backups:FullRemoteVmBackup')
export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote {
_getRemoteWriters() {
return [FullRemoteWriter, AggregatedFullRemoteWriter]
@@ -21,12 +25,23 @@ export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote
for (const metadata of transferList) {
const stream = this._throttleStream(await this._sourceRemoteAdapter.readFullVmBackup(metadata))
const sizeContainer = watchStreamSize(stream)
let checksum
try {
// the unencrypted destinations store the same data as the source: no need to compute their checksum again
checksum = await this._sourceRemoteAdapter.readFullVmBackupChecksum(metadata)
} catch (error) {
warn('error while copying reading source checksum, recomputing it', error)
}
// a single writer can consume the stream itself: no need to fork it
const isForked = this._writers.size > 1
// @todo shouldn't transfer backup if it will be deleted by retention policy (higher retention on source than destination)
await this._callWriters(
writer =>
writer.run({
stream: forkStreamUnpipe(stream),
checksum,
stream: isForked ? forkStreamUnpipe(stream) : stream,
// stream will be forked and transformed, it's not safe to attach additional properties to it
streamLength: stream.length,
maxStreamLength: stream.maxStreamLength, // for encrypted destination/source without length

View File

@@ -62,14 +62,33 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
})
// yeah , let's go
}
/**
* the block files can be copied as stored, never decompressed nor recompressed, if all the writers write VHD
* directories with the same compression (the source disks must also use it, checked per disk when reading)
* @returns {string | undefined} the common compression, undefined if a writer writes plain VHD files or if they differ
*/
#getRawBlocksCompression() {
const compressions = new Set([...this._writers].map(writer => writer.getVhdDirectoryCompression()))
// a single undefined value means plain VHD files on every writer: no raw blocks either
return compressions.size === 1 ? [...compressions][0] : undefined
}
async _run() {
const transferList = await this._computeTransferList(({ mode }) => mode === 'delta')
// the source remote writes with the same compression as all the writers: the blocks can be copied as stored
// this is based on the remote configurations, each source disk checks its own compression since it may predate a
// configuration change, and falls back to a normal read
const writersCompression = this.#getRawBlocksCompression()
const rawBlocksCompression =
writersCompression === this._sourceRemoteAdapter.getVhdDirectoryCompression() ? writersCompression : undefined
const nbTransferrableVms = transferList.length
let nbTransferredVms = 0
for (const metadata of transferList) {
assert.strictEqual(metadata.mode, 'delta')
const incrementalExport = await this._sourceRemoteAdapter.readIncrementalVmBackup(metadata, undefined, {
useChain: false,
rawBlocksCompression,
})
// don't trust metadata too much
// recompute if it's a base backup

View File

@@ -425,8 +425,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
// The synchronized snapshot for this run is taken up-front by the batch
// phase but transferred later. Until it has been exported, hide it from
// retention so it is neither reclaimed nor allowed to steal the delta
// base's "most recent" protection. This makes the pre-transfer pass behave
// retention so it is not reclaimed. This makes the pre-transfer pass behave
// like a normal run, where the snapshot does not exist yet.
if (this._synchronizedSnapshotTimestamp !== undefined) {
const datetime = formatDateTime(this._synchronizedSnapshotTimestamp)
@@ -436,8 +435,11 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
disklessVmSnapshots = disklessVmSnapshots.filter(vm => !isInFlightSyncSnapshot(vm))
}
// get the datetime of the most recent snapshot across both VDI and diskless VM snapshots
const lastSnapshotDateTime = [...this._jobSnapshotVdis, ...disklessVmSnapshots]
const isExported = ({ other_config }) => EXPORTED_SUCCESSFULLY in other_config
// get the datetime of the most recent exported snapshot across both VDI and diskless VM snapshots
const lastExportedSnapshotDateTime = [...this._jobSnapshotVdis, ...disklessVmSnapshots]
.filter(isExported)
.map(({ other_config }) => other_config[DATETIME])
.sort()
.pop()
@@ -457,9 +459,13 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
}
const retention = settings.snapshotRetention ?? 0
await asyncMap(getOldEntries(retention, datetimes), async datetime => {
// keep the last snapshot across all schedules for delta
// keep the last exported snapshot across all schedules for delta
// since we'll need it to compute delta for next backup
if (this.job.mode === 'delta' && datetime === lastSnapshotDateTime) {
if (
this.job.mode === 'delta' &&
lastExportedSnapshotDateTime !== undefined &&
datetime === lastExportedSnapshotDateTime
) {
return
}
const vdis = snapshotPerDatetime[datetime]
@@ -504,6 +510,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
}
if (vm?.$ref !== undefined) {
this._forgetExportedVm(vm.$ref)
return xapi.VM_destroy(vm.$ref)
} else {
return asyncMap(
@@ -529,10 +536,15 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
}
const retention = settings.snapshotRetention ?? 0
await asyncEach(getOldEntries(retention, datetimes), async datetime => {
if (this.job.mode === 'delta' && datetime === lastSnapshotDateTime) {
if (
this.job.mode === 'delta' &&
lastExportedSnapshotDateTime !== undefined &&
datetime === lastExportedSnapshotDateTime
) {
return
}
this._forgetExportedVm(snapshotPerDatetime[datetime])
await xapi.VM_destroy(snapshotPerDatetime[datetime])
})
})
@@ -544,6 +556,15 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
await asyncEach(snapshots, snapshot => snapshot.$destroy())
}
// _exportedVm is the VM (usually a snapshot) this run exported. Retention may destroy
// it in the same run and everything reading it would then operate on a destroyed VM.
// Forget it as soon as it is reclaimed.
_forgetExportedVm(vmRef) {
if (vmRef === this._exportedVm?.$ref) {
this._exportedVm = undefined
}
}
async _removeSnapshotData() {
// now that we use CBT, we can destroy the data of the snapshot used for this backup
// going back to a previous version of XO not supporting CBT will create a full backup

View File

@@ -181,12 +181,10 @@ describe('_removeUnusedSnapshots() in the synchronized batch pre-snapshot state'
name_label: 'base',
is_control_domain: false,
$snapshot_of: 'live-vm-ref',
// exported successfully by the previous run
other_config: { [EXPORTED_SUCCESSFULLY]: 'true' },
}
const baseVdi = {
$ref: 'vdi-base',
other_config: { [DATETIME]: BASE_DATETIME, [SCHEDULE_ID]: 'schedule-1' },
other_config: { [DATETIME]: BASE_DATETIME, [SCHEDULE_ID]: 'schedule-1', [EXPORTED_SUCCESSFULLY]: 'true' },
$VBDs: [{ $VM: baseSnapshotVm }],
}
const registry = { 'vdi-base': baseVdi }
@@ -217,6 +215,118 @@ describe('_removeUnusedSnapshots() in the synchronized batch pre-snapshot state'
})
})
describe('_removeUnusedSnapshots() after a failed transfer', () => {
const BASE_DATETIME = '20260902T090100Z'
const FAILED_DATETIME = '20260902T092200Z'
const makeFailedRunRunner = ({ freshExported = false } = {}) => {
const destroyed = []
const snapshotVm = $ref => ({
$ref,
name_label: $ref,
is_control_domain: false,
$snapshot_of: 'live-vm-ref',
other_config: {},
})
const baseSnapshotVm = snapshotVm('vm-base')
const freshSnapshotVm = snapshotVm('vm-failed')
const vdi = ($ref, datetime, snapshotVmRecord, isExported) => ({
$ref,
other_config: {
[DATETIME]: datetime,
[SCHEDULE_ID]: 'schedule-1',
...(isExported ? { [EXPORTED_SUCCESSFULLY]: 'true' } : {}),
},
$VBDs: [{ $VM: snapshotVmRecord }],
})
// the base of the last successful run and the snapshot of the run that just failed
const baseVdi = vdi('vdi-base', BASE_DATETIME, baseSnapshotVm, true)
const freshVdi = vdi('vdi-failed', FAILED_DATETIME, freshSnapshotVm, freshExported)
const registry = { 'vdi-base': baseVdi, 'vdi-failed': freshVdi }
const runner = makeRunner({
// no synchronized snapshot: this is a plain incremental replication job
_synchronizedSnapshotTimestamp: undefined,
_vm: { uuid: 'live-uuid', $snapshots: [] },
_baseSettings: { snapshotRetention: 0 },
_jobSnapshotVdis: [baseVdi, freshVdi],
_disklessJobSnapshotVms: [],
job: { mode: 'delta', settings: {} },
_xapi: {
barrier: async () => {},
getObject: ref => registry[ref],
VM_destroy: async ref => {
destroyed.push(ref)
},
VDI_destroy: async ref => {
destroyed.push(ref)
},
},
})
return { runner, destroyed }
}
it('keeps the last successfully exported snapshot as the delta base', async () => {
const { runner, destroyed } = makeFailedRunRunner()
await runner._removeUnusedSnapshots()
assert.equal(
destroyed.includes('vm-base'),
false,
'the last exported snapshot is the only usable delta base: destroying it forces a full on the next run'
)
})
it('reclaims the snapshot of the failed run (never exported, retention 0)', async () => {
const { runner, destroyed } = makeFailedRunRunner()
await runner._removeUnusedSnapshots()
assert.deepEqual(destroyed, ['vm-failed'], 'only the never-exported snapshot should be removed')
})
it('keeps the most recent snapshot once the transfer succeeded', async () => {
const { runner, destroyed } = makeFailedRunRunner({ freshExported: true })
await runner._removeUnusedSnapshots()
assert.deepEqual(destroyed, ['vm-base'], 'the newly exported snapshot should replace the previous base')
})
it('keeps the newest exported snapshot even when several runs failed in a row', async () => {
const { runner, destroyed } = makeFailedRunRunner()
// a second consecutive failure adds another never-exported snapshot
const secondFailedVm = {
$ref: 'vm-failed-2',
name_label: 'vm-failed-2',
is_control_domain: false,
$snapshot_of: 'live-vm-ref',
other_config: {},
}
const secondFailedVdi = {
$ref: 'vdi-failed-2',
other_config: { [DATETIME]: '20260902T092600Z', [SCHEDULE_ID]: 'schedule-1' },
$VBDs: [{ $VM: secondFailedVm }],
}
runner._jobSnapshotVdis.push(secondFailedVdi)
const getObject = runner._xapi.getObject
runner._xapi.getObject = ref => (ref === 'vdi-failed-2' ? secondFailedVdi : getObject(ref))
await runner._removeUnusedSnapshots()
assert.deepEqual(
destroyed.sort(),
['vm-failed', 'vm-failed-2'],
'both failed snapshots should be reclaimed and the exported base kept'
)
})
})
describe('_removeUnusedSnapshots() reclaims orphan / CBT snapshot VDIs (no attached VM)', () => {
// Guards the `else` branch: snapshot VDIs that are not attached to any user VM
// (e.g. CBT metadata, orphans) must be reclaimed via VDI_destroy, not VM_destroy.
@@ -325,3 +435,225 @@ describe('_removeUnusedSnapshots() diskless VM snapshots', () => {
assert.deepEqual(destroyed, ['dl-old'], 'the most recent diskless snapshot should be kept in delta mode')
})
})
describe('_removeUnusedSnapshots() forgets the exported snapshot when it reclaims it', () => {
// `_exportedVm` is read after retention has run (by `_removeSnapshotData()`), so it must
// not survive the destruction of the VM it points at.
const BASE_DATETIME = '20260902T090100Z'
const SYNC_TIMESTAMP = 1717200000000
const makeRunner_ = ({ freshDatetime, freshExported = false, synchronized = false, exportedVmRef }) => {
const destroyed = []
const snapshotVm = $ref => ({
$ref,
name_label: $ref,
is_control_domain: false,
$snapshot_of: 'live-vm-ref',
other_config: {},
})
const baseSnapshotVm = snapshotVm('vm-base')
const freshSnapshotVm = snapshotVm('vm-fresh')
const vdi = ($ref, datetime, snapshotVmRecord, isExported) => ({
$ref,
other_config: {
[DATETIME]: datetime,
[SCHEDULE_ID]: 'schedule-1',
...(isExported ? { [EXPORTED_SUCCESSFULLY]: 'true' } : {}),
},
$VBDs: [{ $VM: snapshotVmRecord }],
})
const baseVdi = vdi('vdi-base', BASE_DATETIME, baseSnapshotVm, true)
const freshVdi = vdi('vdi-fresh', freshDatetime, freshSnapshotVm, freshExported)
const registry = { 'vdi-base': baseVdi, 'vdi-fresh': freshVdi }
const vms = { 'vm-base': baseSnapshotVm, 'vm-fresh': freshSnapshotVm }
const runner = makeRunner({
_synchronizedSnapshotTimestamp: synchronized ? SYNC_TIMESTAMP : undefined,
_exportedVm: vms[exportedVmRef],
_vm: { uuid: 'live-uuid', $snapshots: [] },
_baseSettings: { snapshotRetention: 0 },
_jobSnapshotVdis: [baseVdi, freshVdi],
_disklessJobSnapshotVms: [],
job: { mode: 'delta', settings: {} },
_xapi: {
barrier: async () => {},
getObject: ref => registry[ref],
VM_destroy: async ref => {
destroyed.push(ref)
},
VDI_destroy: async ref => {
destroyed.push(ref)
},
},
})
return { runner, destroyed }
}
it('clears _exportedVm when the snapshot of the failed run is reclaimed', async () => {
const { runner, destroyed } = makeRunner_({ freshDatetime: '20260902T092200Z', exportedVmRef: 'vm-fresh' })
await runner._removeUnusedSnapshots()
assert.deepEqual(destroyed, ['vm-fresh'], 'the never-exported snapshot should be reclaimed')
assert.equal(runner._exportedVm, undefined, '_exportedVm must not outlive the VM it points at')
})
it('keeps _exportedVm when the snapshot survives retention (synchronized snapshot)', async () => {
// the in-flight synchronized snapshot is hidden from retention, so it is still
// there afterwards and its data must still be purgeable
const { runner, destroyed } = makeRunner_({
freshDatetime: formatDateTime(SYNC_TIMESTAMP),
synchronized: true,
exportedVmRef: 'vm-fresh',
})
await runner._removeUnusedSnapshots()
assert.deepEqual(destroyed, [], 'the in-flight synchronized snapshot must be kept')
assert.notEqual(runner._exportedVm, undefined, '_exportedVm should be kept while the snapshot exists')
})
it('keeps _exportedVm when another snapshot is reclaimed', async () => {
// nominal run: the fresh snapshot has been exported, the previous base ages out
const { runner, destroyed } = makeRunner_({
freshDatetime: '20260902T092200Z',
freshExported: true,
exportedVmRef: 'vm-fresh',
})
await runner._removeUnusedSnapshots()
assert.deepEqual(destroyed, ['vm-base'], 'only the previous base should be reclaimed')
assert.notEqual(runner._exportedVm, undefined, 'the exported snapshot is untouched')
})
it('clears _exportedVm when a reclaimed diskless snapshot is the exported one', async () => {
const destroyed = []
const oldSnap = { $ref: 'dl-old', other_config: { [DATETIME]: '20240101T000000Z', [SCHEDULE_ID]: 'schedule-1' } }
const registry = { 'dl-old': oldSnap }
const runner = makeRunner({
_exportedVm: oldSnap,
_vm: { uuid: 'live-uuid', $snapshots: [] },
_baseSettings: { snapshotRetention: 0 },
_jobSnapshotVdis: [],
_disklessJobSnapshotVms: [oldSnap],
job: { mode: 'full', settings: {} },
_xapi: {
barrier: async () => {},
getObject: ref => registry[ref],
VM_destroy: async ref => {
destroyed.push(ref)
},
VDI_destroy: async () => {},
},
})
await runner._removeUnusedSnapshots()
assert.deepEqual(destroyed, ['dl-old'], 'the diskless snapshot should be reclaimed')
assert.equal(runner._exportedVm, undefined, '_exportedVm must not outlive the VM it points at')
})
})
describe('run() purges the snapshot data only while the snapshot exists', () => {
// `_removeSnapshotData()` runs from the `finally` of run(), right after
// `_removeUnusedSnapshots()`. Whether it acts is decided by `_exportedVm` alone: the
// snapshot of a failed run is reclaimed (nothing to purge), but a synchronized snapshot
// is hidden from retention and survives, so its data must still be reclaimed.
const makeRunRunner = ({ copyFails = false, isSnapshot = true, snapshotReclaimed = copyFails } = {}) => {
const calls = { markExportSuccessfull: 0, vmDestroyed: [], dataDestroyed: [] }
const runner = makeRunner({
_healthCheckSr: undefined,
// the only configuration in which _removeSnapshotData() acts
_settings: { offlineBackup: false, snapshotRetention: 0, preferNbd: true, cbtDestroySnapshotData: true },
_vm: {
uuid: 'live-uuid',
power_state: 'Halted',
blocked_operations: {},
update_blocked_operations: async () => {},
$call: async () => {},
},
_writers: new Set([{ beforeBackup: async () => {}, afterBackup: async () => {} }]),
_xapi: {
VM_getDisks: async () => ['vdi-ref'],
// used by markExportSuccessfull()
setFieldEntry: async () => {
calls.markExportSuccessfull++
},
call: async (method, ref) => {
calls.vmDestroyed.push(`${method}:${ref}`)
},
VDI_dataDestroy: async ref => {
calls.dataDestroyed.push(ref)
},
},
_cleanMetadata: async () => {},
_fetchJobSnapshots: async () => {},
_selectBaseVm: async () => {},
_removeUnusedSnapshots: async function () {
if (snapshotReclaimed) {
this._forgetExportedVm(this._exportedVm?.$ref)
}
},
_snapshot: async function () {
this._exportedVm = {
$ref: 'snapshot-ref',
is_a_snapshot: isSnapshot,
// a snapshot of the live VM; undefined for an offline backup, where the live VM is exported
$snapshot_of: isSnapshot ? 'live-vm-ref' : undefined,
}
},
_copy: async () => {
if (copyFails) {
throw new Error('transfer failed: target unreachable')
}
},
})
return { runner, calls }
}
it('does not purge anything when the failed run snapshot has been reclaimed', async () => {
const { runner, calls } = makeRunRunner({ copyFails: true })
await assert.rejects(runner.run(), /transfer failed/)
assert.deepEqual(calls.vmDestroyed, [], 'the snapshot is already destroyed: nothing to purge')
assert.deepEqual(calls.dataDestroyed, [], 'no VDI to data_destroy either')
})
it('purges the snapshot data of a failed run whose snapshot survived retention', async () => {
// synchronized snapshot: hidden from retention, so it is still holding its data
const { runner, calls } = makeRunRunner({ copyFails: true, snapshotReclaimed: false })
await assert.rejects(runner.run(), /transfer failed/)
assert.deepEqual(calls.vmDestroyed, ['VM.destroy:snapshot-ref'], 'the surviving snapshot should be destroyed')
assert.deepEqual(calls.dataDestroyed, ['vdi-ref'], 'its data should be reclaimed, as before this fix')
})
it('purges the snapshot data when the transfer succeeded', async () => {
const { runner, calls } = makeRunRunner()
await runner.run()
assert.equal(calls.markExportSuccessfull, 2, 'the exported snapshot and its VDIs should be marked')
assert.deepEqual(calls.vmDestroyed, ['VM.destroy:snapshot-ref'], 'the exported snapshot should be purged')
assert.deepEqual(calls.dataDestroyed, ['vdi-ref'], 'its data should be reclaimed')
})
it('does not purge anything when the live VM was exported (offline backup)', async () => {
const { runner, calls } = makeRunRunner({ isSnapshot: false })
await runner.run()
assert.equal(calls.markExportSuccessfull, 0, 'the live VM is not marked as exported')
assert.deepEqual(calls.vmDestroyed, [], 'the live VM must never be destroyed')
})
})

View File

@@ -13,7 +13,9 @@ export class TaskProgressHandler {
if (progress < 0 || progress > 1) {
return
}
// the end is always emitted: throttled, a last step under the threshold would leave the task at 99%
if (
progress !== 1 &&
this.#lastProgressDate !== undefined &&
this.#lastProgressValue !== undefined &&
Date.now() - this.#lastProgressDate < MAX_DURATION_BETWEEN_PROGRESS_EMIT &&

Some files were not shown because too many files have changed in this diff Show More