Compare commits

...

269 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
1443 changed files with 86268 additions and 9582 deletions

View File

@@ -45,8 +45,8 @@ jobs:
- name: Hold the coverage floor
run: cargo test --locked --test coverage -- --ignored
# builds the binary as a release does, then checks it and the one in git,
# which xo-server runs
# 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:
@@ -65,4 +65,9 @@ jobs:
- run: scripts/build.sh "$RUNNER_TEMP/build"
- run: scripts/check-binary.sh "$RUNNER_TEMP/build/vectura"
- run: scripts/check-binary.sh 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

@@ -19,12 +19,12 @@
},
"main": "./index.mjs",
"dependencies": {
"@xen-orchestra/fs": "^4.9.5",
"@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.2"
"vhd-lib": "^4.17.0"
},
"bin": {
"xo-fuse-vhd": "./cli.mjs"

View File

@@ -15,7 +15,7 @@ The target drives login negotiation onto a single, fixed code path:
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`, `REPORT LUNS`, `READ CAPACITY 10/16`,
(`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
@@ -28,6 +28,19 @@ The package also ships a userspace **initiator** (`IscsiInitiator`) and an
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:
@@ -139,6 +152,8 @@ class RandomAccessDiskLun {
}
return out
}
// reported write-protected: WRITE is refused before reaching write()
readOnly = true
write() {
throw new Error('read-only LUN')
}

View File

@@ -18,14 +18,14 @@ The package has two independent roles with very different maturity:
## Session and connection
| Aspect | Target | Initiator |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- |
| Connections per session | `MaxConnections=1`, enforced | opens exactly one |
| Session reinstatement | **no** — no ISID tracking; a new connection always 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) | 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 |
| 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
@@ -72,17 +72,17 @@ keys if it declines transit on the first exchange.
Decoded explicitly (`types.mts#decodeCdb`); anything else maps to `unsupported` → `CHECK CONDITION` /
`ILLEGAL_REQUEST` / `INVALID COMMAND OPERATION CODE`:
| Command | Variants |
| ------------------- | ---------------------------------------------------------------- |
| `INQUIRY` | standard + EVPD (vendor/product/revision/serial from `identity`) |
| `REPORT LUNS` | |
| `READ CAPACITY` | `(10)` and `(16)` |
| `READ` | `(10)` and `(16)` |
| `WRITE` | `(10)` and `(16)` |
| `TEST UNIT READY` | |
| `REQUEST SENSE` | |
| `MODE SENSE` | `(6)` and `(10)` |
| `SYNCHRONIZE CACHE` | `(10)` and `(16)` — flushes the backing `BlockDevice` |
| 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

View File

@@ -33,7 +33,7 @@ The target drives login negotiation onto a single, fixed code path:
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`, `REPORT LUNS`, `READ CAPACITY 10/16`,
(`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
@@ -46,6 +46,19 @@ The package also ships a userspace **initiator** (`IscsiInitiator`) and an
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:
@@ -157,6 +170,8 @@ class RandomAccessDiskLun {
}
return out
}
// reported write-protected: WRITE is refused before reaching write()
readOnly = true
write() {
throw new Error('read-only LUN')
}

View File

@@ -35,6 +35,11 @@ VM boot mechanics, and deployment specifics live elsewhere and aren't reproduced
- 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

View File

@@ -1,6 +1,6 @@
{
"name": "@vates/iscsi",
"version": "0.2.0",
"version": "0.4.0",
"main": "dist/index.mjs",
"types": "dist/index.d.mts",
"bin": {
@@ -23,7 +23,7 @@
"dependencies": {
"@vates/async-each": "^1.0.3",
"@vates/read-chunk": "^1.2.1",
"@xen-orchestra/disk-transform": "^1.3.0",
"@xen-orchestra/disk-transform": "^1.3.4",
"@xen-orchestra/log": "^0.7.2",
"promise-toolbox": "^0.21.0"
},

View File

@@ -102,10 +102,10 @@ export class DiskBlockDevice implements BlockDevice {
return result
}
/**
* Always throws: the source disk is read-only. The target answers CHECK
* CONDITION / MEDIUM ERROR, which initiators surface as an I/O error.
*/
// 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')
}

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

@@ -16,6 +16,11 @@ export interface BlockDevice {
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`. */
@@ -29,31 +34,60 @@ export interface BlockDevice {
const DEFAULT_BLOCK_SIZE = 512
export interface FileBlockDeviceOptions {
/** Path to the backing file. It must already exist and be sized to capacity. */
/**
* 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 opened read/write.
* 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 }: FileBlockDeviceOptions) {
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. */
@@ -63,12 +97,18 @@ export class FileBlockDevice implements BlockDevice {
}
const handle = await open(this.#path, 'r+')
try {
const { size } = await handle.stat()
if (size % this.#blockSize !== 0) {
throw new Error(`backing file size (${size}) is not a multiple of block size (${this.#blockSize})`)
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
this.#size = size
} catch (error) {
await handle.close()
throw error
@@ -99,6 +139,10 @@ export class FileBlockDevice implements BlockDevice {
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

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/)
})
})
})

View File

@@ -24,7 +24,13 @@ import {
TEXT_FLAG_FINAL,
} from './constants.mjs'
import { buildFixedSense, handleScsiCommand, type ScsiIdentity } from './scsi.mjs'
import { buildSendTargetsResponse, LoginNegotiator, parseTextKeys } from './login.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,
@@ -81,6 +87,16 @@ export interface ConnectionDeps {
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'
@@ -123,6 +139,8 @@ export class Connection implements CommandContext {
readonly #pendingWrites = new Map<number, PendingWrite>()
#nextTargetTransferTag = 1
#loginTimer?: ReturnType<typeof setTimeout>
constructor(socket: Socket, deps: ConnectionDeps) {
this.#socket = socket
this.#deps = deps
@@ -142,9 +160,19 @@ export class Connection implements CommandContext {
/** 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)
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
}
@@ -156,6 +184,7 @@ export class Connection implements CommandContext {
} catch (error) {
this.#fail(error)
} finally {
clearTimeout(this.#loginTimer)
this.#phase = 'closed'
this.#socket.destroy()
}
@@ -299,6 +328,13 @@ export class Connection implements CommandContext {
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.
@@ -316,6 +352,10 @@ export class Connection implements CommandContext {
sessionType: this.#sessionType,
target: this.#login.targetName,
})
if (this.#sessionType === 'Normal') {
clearTimeout(this.#loginTimer)
this.#deps.onSessionEstablished?.(this)
}
}
return false
}
@@ -389,13 +429,13 @@ export class Connection implements CommandContext {
// 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))
.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)
await handleScsiCommand(cdb, pdu.itt, this, this.#deps.identity, this.#deps.maxTransferLength)
}
async #sendReject(pdu: IncomingPdu, reason: number): Promise<void> {

View File

@@ -166,6 +166,7 @@ export const SenseKey = {
HARDWARE_ERROR: 0x04,
ILLEGAL_REQUEST: 0x05,
UNIT_ATTENTION: 0x06,
DATA_PROTECT: 0x07,
} as const
/** Additional Sense Code / Qualifier pairs we may emit. */
@@ -174,6 +175,7 @@ export const Asc = {
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). */

View File

@@ -3,7 +3,7 @@ import { createLogger, type Logger } from '@xen-orchestra/log'
import type { BlockDevice } from './backend.mjs'
import { Connection, type ConnectionDeps } from './connection.mjs'
import type { ScsiIdentity } from './scsi.mjs'
import { DEFAULT_MAX_TRANSFER_LENGTH, type ScsiIdentity } from './scsi.mjs'
import type { ChapCredentials } from './types.mjs'
export { FileBlockDevice } from './backend.mjs'
@@ -25,6 +25,8 @@ 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',
@@ -61,18 +63,62 @@ export interface IscsiTargetOptions {
* 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 always replaces the
* current one rather than being refused (see `#onConnection`): 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. Not RFC 7143 session reinstatement (no
* ISID matching) — safe here since the target is ephemeral, CHAP-guarded, and
* `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 {
@@ -85,9 +131,17 @@ export class IscsiTarget {
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) {
@@ -99,6 +153,16 @@ export class IscsiTarget {
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.
@@ -122,28 +186,44 @@ export class IscsiTarget {
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 existing = this.#connection
if (existing !== undefined) {
// Replaces a possibly-stale connection rather than refusing; see the class doc.
log.warn('replacing existing connection with a new one', { remote: socket.remoteAddress })
existing.destroy()
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.#connection = connection
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) {
@@ -152,6 +232,11 @@ export class IscsiTarget {
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) => {
@@ -175,17 +260,20 @@ export class IscsiTarget {
return address !== null && typeof address === 'object' ? address : undefined
}
/** Stop accepting connections, drop the active connection, and close the LUN. */
/** Stop accepting connections, drop every connection, and close the LUN. */
async close(): Promise<void> {
const server = this.#server
const connection = this.#connection
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 connection so server.close() can complete.
connection?.destroy()
// Drop the live connections so server.close() can complete.
for (const connection of connections) {
connection.destroy()
}
})
}
await this.#lun.close()

View File

@@ -35,9 +35,12 @@ 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.
const OUR_MAX_RECV_DATA_SEGMENT_LENGTH = 262144
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> {

View File

@@ -4,6 +4,7 @@ import { connect, type Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { once } from 'node:events'
import { setTimeout as delay } from 'node:timers/promises'
import { after, before, describe, it } from 'node:test'
import {
@@ -642,6 +643,200 @@ describe('connection replacement', () => {
})
})
// A connection only takes over once it has itself logged in: until then, it
// must not disturb the session in use, whatever it does or fails to do.
describe('connection admission', () => {
const CHAP = { user: 'alice', secret: 's3cr3t-s3cr3t' }
let dir: string
let backingPath: string
before(async () => {
dir = await mkdtemp(join(tmpdir(), 'vates-iscsi-admission-'))
backingPath = join(dir, 'lun.img')
await writeFile(backingPath, Buffer.alloc(LUN_SIZE, 0x5a))
})
after(async () => {
await rm(dir, { recursive: true, force: true })
})
async function withTarget(
options: {
loginTimeoutMs?: number
maxPendingConnections?: number
allowedInitiatorAddresses?: string[]
onConnectionRefused?: (address: string) => void
},
fn: (port: number) => Promise<void>
): Promise<void> {
const target = new IscsiTarget({
iqn: IQN,
host: '127.0.0.1',
port: 0,
lun: new FileBlockDevice({ path: backingPath, blockSize: BLOCK_SIZE }),
chap: CHAP,
...options,
})
await target.listen()
try {
const address = target.address()
assert.ok(address !== undefined)
await fn(address.port)
} finally {
await target.close()
}
}
async function rawConnect(port: number): Promise<Socket> {
const socket = connect(port, '127.0.0.1')
await once(socket, 'connect')
return socket
}
async function assertStillServing(initiator: IscsiInitiator): Promise<void> {
// leave the target time to act on whatever just happened
await delay(100)
assert.deepEqual(await initiator.read(0, BLOCK_SIZE), Buffer.alloc(BLOCK_SIZE, 0x5a))
}
it('a connection that sends nothing does not interrupt the session', async () => {
await withTarget({}, async port => {
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, chap: CHAP })
await initiator.connect()
const probe = await rawConnect(port)
try {
await assertStillServing(initiator)
} finally {
probe.destroy()
await initiator.close()
}
})
})
it('a failed CHAP login does not interrupt the session', async () => {
await withTarget({}, async port => {
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, chap: CHAP })
await initiator.connect()
try {
const intruder = new IscsiInitiator({
host: '127.0.0.1',
port,
targetIqn: IQN,
chap: { user: CHAP.user, secret: 'wrong-wrong-wrong' },
})
await assert.rejects(intruder.connect(), /login rejected/)
await assertStillServing(initiator)
} finally {
await initiator.close()
}
})
})
it('a Discovery session does not interrupt the session', async () => {
await withTarget({}, async port => {
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, chap: CHAP })
await initiator.connect()
const socket = await rawConnect(port)
try {
const discovery = new MiniInitiator(socket)
await discovery.login('Discovery')
assert.equal((await discovery.sendTargets()).get('TargetName'), IQN)
await assertStillServing(initiator)
await discovery.logout()
} finally {
socket.destroy()
await initiator.close()
}
})
})
it('an authenticated login replaces the session', async () => {
await withTarget({}, async port => {
const stale = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, chap: CHAP })
await stale.connect()
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, chap: CHAP })
await initiator.connect()
try {
await assert.rejects(stale.read(0, BLOCK_SIZE), /connection closed/)
await assertStillServing(initiator)
} finally {
await stale.close().catch(() => {})
await initiator.close()
}
})
})
it('closes a connection that does not log in in time', async () => {
await withTarget({ loginTimeoutMs: 100 }, async port => {
const probe = await rawConnect(port)
const closed = once(probe, 'close')
try {
await Promise.race([closed, delay(5000).then(() => assert.fail('the connection should have been closed'))])
} finally {
probe.destroy()
}
})
})
it('closes connections from an address that is not allowed, and reports it', async () => {
const refused: string[] = []
await withTarget(
{ allowedInitiatorAddresses: ['192.0.2.1'], onConnectionRefused: address => refused.push(address) },
async port => {
const probe = await rawConnect(port)
try {
await Promise.race([
once(probe, 'close'),
delay(5000).then(() => assert.fail('the connection should have been closed')),
])
} finally {
probe.destroy()
}
}
)
assert.deepEqual(refused, ['127.0.0.1'])
})
it('serves an allowed address, also in its IPv4-mapped IPv6 form', async () => {
for (const allowed of ['127.0.0.1', '::ffff:127.0.0.1']) {
await withTarget({ allowedInitiatorAddresses: [allowed] }, async port => {
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, chap: CHAP })
await initiator.connect()
try {
await assertStillServing(initiator)
} finally {
await initiator.close()
}
})
}
})
it('refuses connections without a session past the limit', async () => {
await withTarget({ maxPendingConnections: 2 }, async port => {
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, chap: CHAP })
await initiator.connect()
const probes = [await rawConnect(port), await rawConnect(port)]
try {
const extra = await rawConnect(port)
probes.push(extra)
await Promise.race([
once(extra, 'close'),
delay(5000).then(() => assert.fail('the connection should have been refused')),
])
assert.equal(probes[0].destroyed, false)
assert.equal(probes[1].destroyed, false)
await assertStillServing(initiator)
} finally {
for (const probe of probes) {
probe.destroy()
}
await initiator.close()
}
})
})
})
// One loopback that exercises BOTH one-way CHAP roles at once: our target is the
// authenticator (challenges + verifies) and our initiator is the responder
// (answers the challenge). This is the same code path each production role uses

View File

@@ -86,8 +86,12 @@ export class IncomingPdu {
* 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): Promise<IncomingPdu | null> {
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
@@ -98,6 +102,9 @@ export async function readPdu(stream: Readable): Promise<IncomingPdu | null> {
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)

View File

@@ -56,6 +56,24 @@ describe('assemblePdu / readPdu round-trip', () => {
})
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)

View File

@@ -40,13 +40,20 @@ export function buildStandardInquiry(identity: ScsiIdentity): Buffer {
return buffer
}
const SUPPORTED_VPD_PAGES = [0x00, 0x80, 0x83]
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): Buffer | undefined {
export function buildVpd(
pageCode: number,
identity: ScsiIdentity,
maxTransferBlocks = DEFAULT_MAX_TRANSFER_LENGTH / 512
): Buffer | undefined {
switch (pageCode) {
case 0x00: {
// Supported VPD pages.
@@ -83,6 +90,15 @@ export function buildVpd(pageCode: number, identity: ScsiIdentity): Buffer | und
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
}
@@ -125,15 +141,21 @@ export function buildFixedSense({ key, asc, ascq }: SenseInfo): Buffer {
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): Buffer {
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
}
@@ -150,6 +172,7 @@ const ILLEGAL_REQUEST_LBA_OUT_OF_RANGE: SenseInfo = {
...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) })
@@ -161,16 +184,22 @@ function checkCondition(ctx: CommandContext, itt: number, sense: SenseInfo): Pro
* `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
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 })
@@ -183,7 +212,7 @@ export async function handleScsiCommand(
}
return ctx.sendReadData(itt, buildStandardInquiry(identity), request.allocationLength)
}
const page = buildVpd(request.pageCode, identity)
const page = buildVpd(request.pageCode, identity, maxTransferBlocks)
if (page === undefined) {
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
}
@@ -203,6 +232,9 @@ export async function handleScsiCommand(
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)
@@ -221,9 +253,15 @@ export async function handleScsiCommand(
}
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)
@@ -243,7 +281,7 @@ export async function handleScsiCommand(
}
case 'modeSense':
return ctx.sendReadData(itt, buildModeSense(request.long), request.allocationLength)
return ctx.sendReadData(itt, buildModeSense(request.long, lun.readOnly === true), request.allocationLength)
case 'syncCache':
await lun.flush()

View File

@@ -145,9 +145,17 @@ describe('response encoders', () => {
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])
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', () => {
@@ -234,4 +242,61 @@ describe('handleScsiCommand', () => {
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

@@ -56,17 +56,21 @@ function parseAttachedDisk(sessionOutput: string): string | undefined {
}
/**
* `stat()`ing the device node only proves udev created it, not that the SCSI
* layer has finished attaching it — `open()` right after can still fail with
* ENXIO. `/sys/class/block/<name>/size` reporting nonzero is what actually
* means ready, the same check `_cache.mjs`'s `openLocalDevice` uses for the
* same reason on XAPI-plugged devices.
* 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 {
return Number.parseInt(await readFile(`/sys/class/block/${name}/size`, 'utf8'), 10) > 0
} catch {
return false
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
}
}

View File

@@ -54,6 +54,11 @@ export default class AbstractNbdClient {
// 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
@@ -67,7 +72,6 @@ export default class AbstractNbdClient {
#nextCommandQueryId = BigInt(0)
// map of command waiting for a response queryId => { size/*in byte*/, resolve, reject}
#commandQueryBacklog = new Map()
#connected = false
#reconnectingPromise
@@ -108,7 +112,7 @@ export default class AbstractNbdClient {
}
get connected() {
return this.#connected
return this.#connectedGeneration !== undefined
}
/* ---------------------------------------------------------------------------
@@ -202,22 +206,31 @@ export default class AbstractNbdClient {
* connection
* ------------------------------------------------------------------------- */
#onTransportError = error => {
#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 outside of the connected window is expected (the other end can
// be gone already while we're closing), and always reported to the caller
// through the connect()/readBlock() rejection
const log = this.#connected ? warn : debug
log('error on the nbd transport', { error })
// 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)
}
#watchTransport(transport) {
transport.readable.on('error', this.#onTransportError)
// 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', this.#onTransportError)
transport.writable.on('error', onError)
}
}
@@ -227,10 +240,10 @@ export default class AbstractNbdClient {
// 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)
this.#watchTransport(transport, generation)
try {
// the transport can be replaced during the handshake (TLS upgrade)
transport = await this.#handshake(transport)
transport = await this.#handshake(transport, generation)
if (generation !== this.#connectGeneration) {
const error = new Error('this connection has been superseded by a newer one')
@@ -244,7 +257,7 @@ export default class AbstractNbdClient {
throw error
}
this.#transport = transport
this.#connected = true
this.#connectedGeneration = generation
// reset internal state if we reconnected a nbd client
this.#commandQueryBacklog = new Map()
this.#waitingForResponse = false
@@ -267,10 +280,10 @@ export default class AbstractNbdClient {
}
async disconnect() {
if (!this.#connected) {
if (this.#connectedGeneration === undefined) {
return
}
this.#connected = false
this.#connectedGeneration = undefined
const transport = this.#transport
this.#transport = undefined
if (transport === undefined) {
@@ -366,9 +379,10 @@ export default class AbstractNbdClient {
//
/**
* @param {NbdTransport} transport
* @param {number} generation - the connection attempt owning the transport
* @returns {Promise<NbdTransport>}
*/
async #handshake(transport) {
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)
@@ -379,7 +393,7 @@ export default class AbstractNbdClient {
// let the subclass upgrade the transport if it needs to (TLS)
const secured = await this._secureTransport(transport)
if (secured !== transport) {
this.#watchTransport(secured)
this.#watchTransport(secured, generation)
transport = secured
}

View File

@@ -166,6 +166,28 @@ describe('AbstractNbdClient', () => {
}
})
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')

View File

@@ -1,4 +1,7 @@
import { Socket } from 'node:net'
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'
@@ -11,6 +14,9 @@ import { NBD_DEFAULT_PORT, NBD_OPT_STARTTLS } from './constants.mjs'
* @extends {AbstractNbdClient}
*/
export default class NbdTcpClient extends AbstractNbdClient {
#httpProxy
#proxyRejectUnauthorized
#proxyTimeout
#serverAddress
#serverCert
#serverPort
@@ -21,18 +27,103 @@ export default class NbdTcpClient extends AbstractNbdClient {
* @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 }, options) {
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)

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

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

View File

@@ -1,7 +1,7 @@
{
"name": "@vates/types",
"private": false,
"version": "2.3.0",
"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

@@ -130,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 }
@@ -147,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'] }>,
@@ -283,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,

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,4 +1,4 @@
import { EventEmitter } from 'node:stream'
import type { EventEmitter, Readable } from 'node:stream'
import type {
AnyXoJob,
AnyXoLog,
@@ -9,19 +9,25 @@ import type {
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,
@@ -175,13 +181,13 @@ export type PoolRollingUpdateRecoveryBlocked = {
export type PoolRollingUpdateRecovery = PoolRollingUpdateRecoveryRun | PoolRollingUpdateRecoveryBlocked
/** A disk of a backup archive currently served as a read-only iSCSI LUN */
/** 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 read-only VDI exposing the backup disk */
/** 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
@@ -191,7 +197,25 @@ export type BackupArchiveDiskMount = {
port: number
}
export type XoApp = {
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>
@@ -233,12 +257,6 @@ export type XoApp = {
}
}
/**
* the VM backup archives of the backup repositories, as a collection: `add`, `update` and
* `remove` events carrying the archive and its previous value
*/
vmBackupArchives: EventEmitter
tasks: EventEmitter & {
abort(id: XoTask['id'], reason?: string): Promise<void>
clearLogs(): Promise<void>
@@ -252,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>
@@ -276,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) */
@@ -296,6 +317,13 @@ export type XoApp = {
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
@@ -330,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[]>
@@ -342,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 }
@@ -356,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[]>
@@ -388,6 +421,10 @@ export type XoApp = {
}
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
@@ -405,8 +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>(
@@ -416,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[]>>
@@ -428,19 +482,41 @@ 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 a read-only iSCSI LUN and attach it to
* `host` as an SR. Undone by `unmountBackupArchiveDisk`.
* 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
@@ -481,6 +557,7 @@ export type XoApp = {
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<
@@ -490,6 +567,7 @@ export type XoApp = {
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 }

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
@@ -208,6 +231,7 @@ export type XoBackupRepositoryBenchmark = {
}
export type XoBackupRepository = {
available?: number
benchmarks?: XoBackupRepositoryBenchmark[]
enabled: boolean
error?: Record<string, unknown>
@@ -215,7 +239,9 @@ export type XoBackupRepository = {
name: string
options?: string
proxy?: XoProxy['id']
size?: number
url: string
used?: number
}
export type XoGpuGroup = BaseXapiXo & {
@@ -457,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
@@ -609,6 +658,7 @@ export type XoServer = {
label?: string
master?: XoHost['id']
poolId?: XoPool['id']
poolMembersAddresses?: string[]
poolNameDescription?: string
poolNameLabel?: string
readOnly: boolean
@@ -697,6 +747,7 @@ export type XoTask = {
| 'backup-log'
| 'backup-repository'
| 'group'
| 'plugin'
| 'proxy'
| 'restore'
| 'restore-log'
@@ -910,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.7.0",
"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.3.0",
"@vates/types": "^2.5.0",
"rimraf": "^6.0.1",
"typescript": "~5.6",
"typescript-eslint": "^8.61.0"

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

@@ -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,6 +1,6 @@
{
"name": "@xen-orchestra/backup-archive",
"version": "1.1.3",
"version": "1.2.0",
"description": "Helper to handle generic backup archives",
"license": "ISC",
"author": "",
@@ -21,18 +21,18 @@
"tar-stream": "^3.1.8",
"@types/tar-stream": "^3.1.4",
"tmp": "^0.2.1",
"@vates/types": "^2.3.0"
"@vates/types": "^2.5.0"
},
"dependencies": {
"@xen-orchestra/log": "^0.7.1",
"@xen-orchestra/fs": "^4.9.5",
"@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.2"
"vhd-lib": "^4.17.0"
},
"scripts": {
"build": "yarn run clean && tsc",

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

@@ -8,8 +8,8 @@
"dependencies": {
"@vates/async-each": "^1.0.3",
"@xen-orchestra/async-map": "^0.1.5",
"@xen-orchestra/backups": "^0.76.1",
"@xen-orchestra/fs": "^4.9.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.16",
"version": "1.1.18",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Vates SAS",

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'
@@ -42,7 +43,8 @@ export class ImportVmBackup {
* @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 {({ diskPath, hostId }) => Promise<{ id: string, vdiUuid: string }>} params.liveMount.mountDisk
* @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]
*/
@@ -356,11 +358,23 @@ export class ImportVmBackup {
}
const diskPath = join(metadataDir, metadata.vhds[vdiRef])
const mount = await liveMount.mountDisk({ diskPath, hostId })
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', { diskPath, hostId, mountId: mount.id, vdiUuid: vdi.uuid })
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) }
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

View File

@@ -141,7 +141,20 @@ describe('ImportVmBackup#_decorateIncrementalVmMetadata()', () => {
const backup = await importer._decorateIncrementalVmMetadata()
assert.deepEqual(liveMount.mounted, [{ diskPath: `/xo-vm-backups/${VM_UUID}/${vhdPath(DATA)}`, hostId: HOST }])
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])
})
@@ -153,6 +166,7 @@ describe('ImportVmBackup#_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
@@ -160,6 +174,19 @@ describe('ImportVmBackup#_decorateIncrementalVmMetadata()', () => {
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: {

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,6 +3,7 @@ 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'
@@ -317,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()
}
@@ -676,7 +685,7 @@ export class RemoteAdapter {
path,
concurrency: writeBlockConcurrency,
validator,
compression: handler.getConfig('compressionType') ?? 'brotli', // compatibility layer
compression: this.getVhdDirectoryCompression(),
uuid,
parentUuid,
parentPath,
@@ -702,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,
@@ -715,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)
@@ -735,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 {
@@ -753,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

@@ -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

@@ -294,9 +294,9 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
await asyncMap(Object.values(vdiVbds), vbd =>
xapi.VBD_create({
...vbd,
// a 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 ? 'RO' : vbd.mode,
// 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,
})
@@ -307,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

@@ -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

@@ -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 &&

View File

@@ -1,5 +1,15 @@
import { AbstractAggregatedRemoteWriter } from './_AbstractAggregatedRemoteWriter.mjs'
export class AggregatedIncrementalRemoteWriter extends AbstractAggregatedRemoteWriter {
/**
* the backup can land on any of the adapters, the compression is only known if they all use the same one
* (based on the adapters since the child writers are only created in beforeBackup())
* @returns {string | undefined} see RemoteAdapter#getVhdDirectoryCompression
*/
getVhdDirectoryCompression() {
const compressions = new Set(Object.values(this.adapters).map(adapter => adapter.getVhdDirectoryCompression()))
return compressions.size === 1 ? [...compressions][0] : undefined
}
async deleteOldBackupsOnAdapter(adapter, backups) {
await adapter.deleteDeltaVmBackups(backups)
}

View File

@@ -25,7 +25,11 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
)
}
async _run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
/**
* @param {object} params
* @param {string} [params.checksum] - checksum of the stream data if known (full mirror of an unencrypted backup)
*/
async _run({ checksum, maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
const settings = this._settings
const job = this._job
const scheduleId = this._schedule.id
@@ -67,6 +71,7 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
await Task.run({ properties: { name: 'transfer' } }, async () => {
await adapter.outputStream(dataFilename, stream, {
checksum,
maxStreamLength,
streamLength,
validator: tmpPath => adapter.isValidXva(tmpPath),

View File

@@ -25,6 +25,14 @@ const { warn } = createLogger('xo:backups:DeltaBackupWriter')
export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrementalWriter) {
#parentVdiPaths
#parentUuids
/**
* @returns {string | undefined} see RemoteAdapter#getVhdDirectoryCompression
*/
getVhdDirectoryCompression() {
return this._adapter.getVhdDirectoryCompression()
}
async checkBaseVdis(baseUuidToSrcVdi) {
this.#parentVdiPaths = {}
this.#parentUuids = {}

View File

@@ -391,6 +391,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
mostRecentEntry = this._oldEntries.pop()
}
await this._deleteOldEntries()
await this._deleteOrphanVdis()
this._oldEntries = mostRecentEntry !== undefined ? [mostRecentEntry] : []
}
}
@@ -398,6 +399,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
async cleanup() {
if (!this._settings.skipDeleteOldEntries) {
await this._deleteOldEntries()
await this._deleteOrphanVdis()
}
}
@@ -405,6 +407,35 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
return asyncMapSettled(this._oldEntries, vm => vm.$destroy({ bypassBlockedOperation: true }))
}
// VDIs from this job/VM/SR that ended up detached from any VM, because a previous run
// was interrupted or a storage backend refused to destroy a VDI at import time.
// Destroying a VM only reaches the VDIs still attached to it, _deleteOldEntries never cleans them.
async _deleteOrphanVdis() {
const sr = this._sr
const vmUuid = this._vmUuid
const jobId = this._job.id
const orphanVdis = sr.$VDIs.filter(
vdi =>
vdi?.managed &&
vdi.other_config[JOB_ID] === jobId &&
vdi.other_config[VM_UUID] === vmUuid &&
vdi.other_config[REPLICATED_TO_SR_UUID] === sr.uuid &&
vdi.type !== 'suspend' &&
// a VBD or VM missing from the cache (e.g. just destroyed) keeps the VDI, next run will handle it
vdi.$VBDs.every(vbd => vbd?.$VM?.is_control_domain === true)
)
await asyncEach(
orphanVdis,
vdi =>
sr.$xapi
.call('VDI.destroy', vdi.$ref)
.catch(error => Task.warning('failed to delete orphan VDI', { vdi: vdi.uuid, error })),
{ concurrency: 5 }
)
}
#decorateVmMetadata(backup, timestamp) {
const { _warmMigration } = this._settings
const sr = this._sr

View File

@@ -1,9 +1,18 @@
import { AbstractWriter } from './_AbstractWriter.mjs'
export class AbstractFullWriter extends AbstractWriter {
async run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
async run({ checksum, maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
try {
return await this._run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot })
return await this._run({
checksum,
maxStreamLength,
timestamp,
sizeContainer,
stream,
streamLength,
vm,
vmSnapshot,
})
} finally {
// ensure stream is properly closed
stream.destroy()

View File

@@ -12,11 +12,14 @@ const { warn } = createLogger('xo:backups:vdiRestoreTargets')
*/
/**
* Attach the backup disk itself to the restored VM, through a live mount: no copy, read only.
* Attach the backup disk itself to the restored VM, through a live mount: no copy, read only —
* unless it is cached, which makes it read/write.
*
* @typedef {object} LiveMountVdiTarget
* @property {'live-mount'} type
* @property {string} host - uuid of the host the disk is attached to
* @property {string} [cacheSr] - uuid of the SR of the local VDI the disk is materialized into, which
* also holds the writes, lost on unmount
*/
/**
@@ -133,6 +136,20 @@ export function hasLiveMountTarget(mapVdisSrs) {
return Object.values(mapVdisSrs ?? {}).some(value => value?.type === 'live-mount')
}
/**
* Whether a raw `mapVdisSrs` setting asks for at least one cached, hence read/write, live mount.
*
* Such a mount needs the VM of the appliance serving it, which the caller has to look up. A `null`
* cache SR does not ask for one: it is invalid, and left to {@link normalizeVdiRestoreTargets} to
* reject, rather than failing on the lookup of a VM which was never needed.
*
* @param {object} [mapVdisSrs]
* @returns {boolean}
*/
export function hasCachedLiveMountTarget(mapVdisSrs) {
return Object.values(mapVdisSrs ?? {}).some(value => value?.type === 'live-mount' && value.cacheSr != null)
}
/**
* Normalize the `mapVdisSrs` restore setting into per-VDI targets.
*
@@ -186,10 +203,17 @@ function normalizeTarget(vdiUuid, value, useDifferentialRestore) {
}
if (type === 'live-mount') {
if (!isNonEmptyString(value.host)) {
const { cacheSr, host } = value
if (!isNonEmptyString(host)) {
throw invalidTarget(vdiUuid, 'a live mount requires the id of the host the disk is attached to')
}
return { type: 'live-mount', host: value.host }
if (cacheSr === undefined) {
return { type: 'live-mount', host }
}
if (!isNonEmptyString(cacheSr)) {
throw invalidTarget(vdiUuid, `expected the uuid of the cache SR, got ${typeof cacheSr}`)
}
return { type: 'live-mount', host, cacheSr }
}
if (type === 'restore') {

View File

@@ -1,7 +1,7 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { hasLiveMountTarget, normalizeVdiRestoreTargets } from './_vdiRestoreTargets.mjs'
import { hasCachedLiveMountTarget, hasLiveMountTarget, normalizeVdiRestoreTargets } from './_vdiRestoreTargets.mjs'
const VDI_1 = 'vdi-uuid-1'
const VDI_2 = 'vdi-uuid-2'
@@ -64,6 +64,11 @@ describe('normalizeVdiRestoreTargets()', () => {
assert.equal(targets.getLiveMountHost(), HOST)
})
it('keeps the cache SR of a live mount', () => {
const targets = normalizeVdiRestoreTargets({ [VDI_1]: { type: 'live-mount', host: HOST, cacheSr: SR } })
assert.deepEqual(targets.get(VDI_1), { type: 'live-mount', host: HOST, cacheSr: SR })
})
it('accepts a restore without SR', () => {
const targets = normalizeVdiRestoreTargets({ [VDI_1]: { type: 'restore' } })
assert.deepEqual(targets.get(VDI_1), { type: 'restore', sr: undefined, useDifferential: false })
@@ -97,6 +102,13 @@ describe('normalizeVdiRestoreTargets()', () => {
)
})
it('rejects a non string cache SR', () => {
assert.throws(
() => normalizeVdiRestoreTargets({ [VDI_1]: { type: 'live-mount', host: HOST, cacheSr: null } }),
/expected the uuid of the cache SR/
)
})
it('rejects a non string SR', () => {
assert.throws(() => normalizeVdiRestoreTargets({ [VDI_1]: { type: 'restore', sr: 42 } }), /expected an SR uuid/)
})
@@ -130,3 +142,20 @@ describe('hasLiveMountTarget()', () => {
assert.equal(hasLiveMountTarget({ [VDI_1]: SR, [VDI_2]: null, [VDI_3]: { type: 'ignore' } }), false)
})
})
describe('hasCachedLiveMountTarget()', () => {
it('detects a cached live mount entry', () => {
assert.equal(hasCachedLiveMountTarget({ [VDI_1]: { type: 'live-mount', host: HOST, cacheSr: SR } }), true)
})
it('is false for an uncached live mount, and every other shape', () => {
assert.equal(hasCachedLiveMountTarget(undefined), false)
assert.equal(hasCachedLiveMountTarget({ [VDI_1]: { type: 'live-mount', host: HOST } }), false)
assert.equal(hasCachedLiveMountTarget({ [VDI_1]: SR, [VDI_2]: null, [VDI_3]: { type: 'ignore' } }), false)
})
// the normalization rejects it, which is the error worth reporting
it('is false for a null cache SR', () => {
assert.equal(hasCachedLiveMountTarget({ [VDI_1]: { type: 'live-mount', host: HOST, cacheSr: null } }), false)
})
})

View File

@@ -0,0 +1,28 @@
import { formatFilenameDate } from './_filenameDate.mjs'
/**
* XAPI names of what a live mount of a backed up disk shows the user, so the ones created by the REST
* API and by a restore read the same.
*
* The VDI attached to the host keeps the name of the backed up disk: that is the disk of the VM as
* the user knows it. The SR says what it is, and which restore point it serves, the same way as a
* restored VM is named.
*
* @param {object} params
* @param {string} params.vmNameLabel - name of the backed up VM
* @param {string} params.vdiNameLabel - name of the backed up disk
* @param {number} params.timestamp - of the restore point, in ms
* @param {boolean} [params.readWrite] - whether the mount is a live restore rather than a live mount
* @returns {{ srNameLabel: string, vdiNameLabel: string, vdiNameDescription: string }} - the `xapiLabels` of the live mount mixin
*/
export function liveMountXapiLabels({ vmNameLabel, vdiNameLabel, timestamp, readWrite = false }) {
const what = readWrite ? 'live restore' : 'live mount'
const restorePoint = `${vmNameLabel} (${formatFilenameDate(new Date(timestamp))})`
return {
srNameLabel: `[XO ${what}] ${restorePoint}`,
vdiNameLabel,
vdiNameDescription: readWrite
? `Live restore of ${restorePoint}: what is written to it is lost on unmount.`
: `Read-only live mount of ${restorePoint}, removed on unmount.`,
}
}

View File

@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { liveMountXapiLabels } from './liveMountXapiLabels.mjs'
const TIMESTAMP = 1754035712000 // 2025-08-01T08:08:32Z
describe('liveMountXapiLabels', () => {
it('keeps the name of the disk and names the SR after the restore point', () => {
assert.deepEqual(liveMountXapiLabels({ timestamp: TIMESTAMP, vdiNameLabel: 'system', vmNameLabel: 'web01' }), {
srNameLabel: '[XO live mount] web01 (20250801T080832Z)',
vdiNameLabel: 'system',
vdiNameDescription: 'Read-only live mount of web01 (20250801T080832Z), removed on unmount.',
})
})
it('says a live restore loses what is written to it', () => {
const xapiLabels = liveMountXapiLabels({
readWrite: true,
timestamp: TIMESTAMP,
vdiNameLabel: 'system',
vmNameLabel: 'web01',
})
assert.equal(xapiLabels.srNameLabel, '[XO live restore] web01 (20250801T080832Z)')
assert.match(xapiLabels.vdiNameDescription, /lost on unmount/)
})
})

View File

@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.76.1",
"version": "0.78.0",
"engines": {
"node": ">=14.18"
},
@@ -27,17 +27,17 @@
"@vates/disposable": "^0.1.6",
"@vates/fuse-vhd": "^2.1.3",
"@vates/generator-toolbox": "^1.1.3",
"@vates/nbd-client": "^3.6.0",
"@vates/nbd-client": "^3.7.0",
"@vates/parse-duration": "^0.1.1",
"@vates/task": "^0.7.0",
"@vates/types": "^2.3.0",
"@vates/types": "^2.5.0",
"@xen-orchestra/async-map": "^0.1.5",
"@xen-orchestra/disk-transform": "^1.3.3",
"@xen-orchestra/fs": "^4.9.5",
"@xen-orchestra/disk-transform": "^1.3.4",
"@xen-orchestra/fs": "^4.10.0",
"@xen-orchestra/log": "^0.7.2",
"@xen-orchestra/qcow2": "^1.4.0",
"@xen-orchestra/qcow2": "^1.4.1",
"@xen-orchestra/template": "^0.1.1",
"@xen-orchestra/backup-archive": "^1.1.3",
"@xen-orchestra/backup-archive": "^1.2.0",
"app-conf": "^3.0.0",
"compare-versions": "^6.0.0",
"d3-time-format": "^4.1.0",
@@ -55,8 +55,8 @@
"tar": "^7.5.19",
"uuid": "^9.0.0",
"value-matcher": "^0.2.0",
"vhd-lib": "^4.16.2",
"xen-api": "^5.0.2",
"vhd-lib": "^4.17.0",
"xen-api": "^5.1.0",
"yazl": "^2.5.1"
},
"devDependencies": {
@@ -67,7 +67,7 @@
"typescript": "^5.9.3"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^9.0.1"
"@xen-orchestra/xapi": "^9.1.0"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

@@ -18,7 +18,7 @@
"preferGlobal": true,
"dependencies": {
"golike-defer": "^0.5.1",
"xen-api": "^5.0.2"
"xen-api": "^5.1.0"
},
"scripts": {
"postversion": "npm publish"

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/disk-cli",
"version": "2.2.3",
"version": "2.2.5",
"description": "CLI tool to inspect and manage disks in backup repositories",
"type": "module",
"bin": {
@@ -20,13 +20,13 @@
},
"dependencies": {
"@vates/async-each": "^1.0.3",
"@xen-orchestra/backups": "^0.76.1",
"@xen-orchestra/backup-archive": "^1.1.3",
"@xen-orchestra/disk-transform": "^1.3.3",
"@xen-orchestra/fs": "^4.9.5",
"@xen-orchestra/qcow2": "^1.4.0",
"@xen-orchestra/backups": "^0.78.0",
"@xen-orchestra/backup-archive": "^1.2.0",
"@xen-orchestra/disk-transform": "^1.3.4",
"@xen-orchestra/fs": "^4.10.0",
"@xen-orchestra/qcow2": "^1.4.1",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.16.2"
"vhd-lib": "^4.17.0"
},
"devDependencies": {
"@types/node": "^22.3.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/disk-transform",
"version": "1.3.3",
"version": "1.3.4",
"main": "./dist/index.mjs",
"license": "MIT",
"private": false,

View File

@@ -12,11 +12,11 @@ export class ThrottledDisk extends DiskPassthrough {
const generator = await this.source.buildDiskBlockGenerator()
//throttle want to be able to know the length of the data
async function* generatorWithLength() {
for await (const { index, data } of generator) {
for await (const block of generator) {
// keep the other properties of the block (like the compression of a raw VHD directory block)
yield {
index,
data,
length: data.length,
...block,
length: block.data.length,
}
}
}

View File

@@ -0,0 +1,32 @@
import { test } from 'node:test'
import assert from 'node:assert'
import { Throttle } from '@vates/generator-toolbox'
import { DiskBlock } from './Disk.mjs'
import { DebugDisk } from './utils/DebugDisk.mjs'
import { ThrottledDisk } from './Throttled.mjs'
type TaggedDiskBlock = DiskBlock & { compression: string }
// a disk yielding blocks with an additional property, like the raw blocks of a VHD directory
class TaggedDebugDisk extends DebugDisk {
async readBlock(index: number): Promise<TaggedDiskBlock> {
return { ...(await super.readBlock(index)), compression: 'brotli' }
}
}
test('ThrottledDisk keeps the other properties of the blocks', async () => {
const blockSize = 1024
const nbBlocks = 5
const source = new TaggedDebugDisk({ nbBlocks, blockSize, fillRate: 100 })
await source.init()
// speed 0 disables the throttling
const disk = new ThrottledDisk(source, new Throttle(0))
let nbBlocksRead = 0
for await (const block of disk.diskBlocks()) {
assert.strictEqual((block as TaggedDiskBlock).compression, 'brotli')
assert.strictEqual(block.data.length, blockSize)
nbBlocksRead++
}
assert.strictEqual(nbBlocksRead, nbBlocks)
})

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/fs",
"version": "4.9.5",
"version": "4.10.0",
"license": "AGPL-3.0-or-later",
"description": "The File System for Xen Orchestra backups.",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",

View File

@@ -28,6 +28,7 @@ function getEncryptor(algorithm = DEFAULT_ENCRYPTION_ALGORITHM, key) {
ivLength: 0,
authTagLength: 0,
encryptData: buffer => buffer,
encryptDataParts: buffers => buffers,
encryptStream: stream => stream,
decryptData: buffer => buffer,
decryptStream: stream => stream,
@@ -109,11 +110,17 @@ function getEncryptor(algorithm = DEFAULT_ENCRYPTION_ALGORITHM, key) {
)
}
function encryptData(buffer) {
// data can be an array of buffers, encrypted as their concatenation
// returns the encrypted file as an array of buffers, to be written one after the other
function encryptDataParts(data) {
const iv = crypto.randomBytes(ivLength)
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv)
const encrypted = cipher.update(buffer)
return Buffer.concat([iv, encrypted, cipher.final(), authTagLength > 0 ? cipher.getAuthTag() : Buffer.alloc(0)])
const encrypted = Array.isArray(data) ? data.map(buffer => cipher.update(buffer)) : [cipher.update(data)]
return [iv, ...encrypted, cipher.final(), authTagLength > 0 ? cipher.getAuthTag() : Buffer.alloc(0)]
}
function encryptData(data) {
return Buffer.concat(encryptDataParts(data))
}
function decryptData(buffer) {
@@ -128,7 +135,10 @@ function getEncryptor(algorithm = DEFAULT_ENCRYPTION_ALGORITHM, key) {
encrypted = buffer.slice(ivLength)
}
const decrypted = decipher.update(encrypted)
return Buffer.concat([decrypted, decipher.final()])
// final() must always be called: it checks the auth tag
const final = decipher.final()
// nothing is left with stream modes (like GCM): no need to copy the data
return final.length === 0 ? decrypted : Buffer.concat([decrypted, final])
}
return {
@@ -138,6 +148,7 @@ function getEncryptor(algorithm = DEFAULT_ENCRYPTION_ALGORITHM, key) {
authTagLength,
ivLength,
encryptData,
encryptDataParts,
encryptStream,
decryptData,
decryptStream,

View File

@@ -36,6 +36,16 @@ algorithms.forEach(algorithm => {
assert.equal(decrypted.equals(buffer), true)
})
it('handle an array of buffers', () => {
// like a VHD block: a bitmap then the data, sizes not aligned on the cipher block size
const parts = [crypto.randomBytes(512), crypto.randomBytes(1024 * 1024 + 1)]
const encrypted = Buffer.concat(encryptor.encryptDataParts(parts))
// same layout as a single buffer: existing remotes must still be readable
const decrypted = encryptor.decryptData(encrypted)
assert.equal(decrypted.equals(Buffer.concat(parts)), true)
})
it('handle stream', async () => {
const stream = Readable.from(buffer)
stream.length = buffer.length

View File

@@ -26,6 +26,10 @@ const computeRate = (hrtime, size) => {
const DEFAULT_TIMEOUT = 12e5 // 20 min
const DEFAULT_MAX_PARALLEL_OPERATIONS = 10
// size of the chunks of the file streams, same as the backup streams: with the 64 KiB of Node, each chunk costs a
// round trip to the thread pool, which limits a sequential read to about 1.5 GB/s
const DEFAULT_HIGH_WATER_MARK = 10 * 1024 * 1024
const ENCRYPTION_DESC_FILENAME = 'encryption.json'
const ENCRYPTION_METADATA_FILENAME = 'metadata.json'
@@ -158,7 +162,7 @@ export default class RemoteHandlerAbstract {
}
}
;({
highWaterMark: this._highWaterMark,
highWaterMark: this._highWaterMark = DEFAULT_HIGH_WATER_MARK,
timeout: this._timeout = DEFAULT_TIMEOUT,
withLimit: this._withLimit = WITH_LIMIT,
withTimeout: this._withTimeout = WITH_TIMEOUT,
@@ -289,12 +293,19 @@ export default class RemoteHandlerAbstract {
* @param {string} path
* @param {ReadableStream} input
* @param {object} [options]
* @param {boolean} [options.checksum]
* @param {boolean|string} [options.checksum=true] - whether to compute and store the checksum of the stored data,
* or this checksum itself when it is already known (an unencrypted file copied from another remote): it is then
* stored as is, without reading the data
* @param {number} [options.dirMode]
* @param {(this: RemoteHandlerAbstract, path: string) => Promise<undefined>} [options.validator] Function that will be called before the data is commited to the remote, if it fails, file should not exist
*/
async outputStream(path, input, { checksum = true, dirMode, maxStreamLength, streamLength, validator } = {}) {
path = normalizePath(path)
const knownChecksum = typeof checksum === 'string' ? checksum : undefined
if (knownChecksum !== undefined) {
// the checksum is the one of the stored data, which are different once encrypted
assert.strictEqual(this.isEncrypted, false, `a known checksum can't be used on an encrypted remote for ${path}`)
}
let checksumStream
info('will output stream ', { path, streamLength, maxStreamLength, isEncrypted: this.isEncrypted })
if (this.isEncrypted) {
@@ -305,7 +316,7 @@ export default class RemoteHandlerAbstract {
info('stream is updated ', { path, streamLength, maxStreamLength, overhead })
}
input = this.#encryptor.encryptStream(input)
if (checksum) {
if (checksum === true) {
checksumStream = createChecksumStream()
pipeline(input, checksumStream, noop)
input = checksumStream
@@ -319,7 +330,10 @@ export default class RemoteHandlerAbstract {
if (checksum) {
// using _outputFile means the checksum will NOT be encrypted
// it is by design to allow checking of encrypted files without the key
await this._outputFile(checksumFile(path), await checksumStream.checksum, { dirMode, flags: 'wx' })
await this._outputFile(checksumFile(path), knownChecksum ?? (await checksumStream.checksum), {
dirMode,
flags: 'wx',
})
}
}
@@ -383,8 +397,15 @@ export default class RemoteHandlerAbstract {
await this._mktree(normalizePath(dir), { mode })
}
// data can be an array of buffers, the file is then their concatenation
async outputFile(file, data, { dirMode, flags = 'wx' } = {}) {
const encryptedData = this.#encryptor.encryptData(data)
// the encrypted parts are not concatenated when the handler can write them as is
let encryptedData = this._writesBufferArrays
? this.#encryptor.encryptDataParts(data)
: this.#encryptor.encryptData(data)
if (Array.isArray(encryptedData) && !this._writesBufferArrays) {
encryptedData = Buffer.concat(encryptedData)
}
await this._outputFile(normalizePath(file), encryptedData, { dirMode, flags })
}
@@ -789,6 +810,11 @@ export default class RemoteHandlerAbstract {
throw new Error('Not implemented')
}
// whether _writeFile accepts an array of buffers (written one after the other, without concatenating them)
get _writesBufferArrays() {
return false
}
get isEncrypted() {
return this.#encryptor.id !== 'NULL_ENCRYPTOR'
}

View File

@@ -249,8 +249,19 @@ export default class AzureHandler extends RemoteHandlerAbstract {
}
file = this.#makeFullPath(file)
const blobClient = this.#containerClient.getBlobClient(file)
const properties = await blobClient.getProperties()
return properties.contentLength
try {
const properties = await blobClient.getProperties()
return properties.contentLength
} catch (e) {
// a HEAD response has no body, so there is no BlobNotFound code to match
if (e.name === 'RestError' && e.statusCode === 404) {
const error = new Error(`ENOENT: no such file '${file}'`, { cause: e })
error.code = 'ENOENT'
error.path = file
throw error
}
throw e
}
}
async _read(file, buffer, position = 0) {

View File

@@ -51,6 +51,22 @@ function dontAddSyncStackTrace(fn, ...args) {
return fn.apply(this, args)
}
// writes the buffers one after the other with writev: no need to concatenate them
// libuv already resumes the partial writes, a short count means the write stopped (like a full disk)
// fs-extra goes through graceful-fs: an EMFILE on open is queued, like with fs.writeFile
async function writeBuffers(path, buffers, flags) {
const fd = await fs.open(path, flags)
try {
const length = buffers.reduce((sum, buffer) => sum + buffer.length, 0)
const { bytesWritten } = await fs.writev(fd, buffers, 0)
if (bytesWritten !== length) {
throw new Error(`incomplete write of ${path}: ${bytesWritten} of ${length} bytes`)
}
} finally {
await fs.close(fd)
}
}
export default class LocalHandler extends RemoteHandlerAbstract {
#addSyncStackTrace
#retriesOnEagain
@@ -250,6 +266,13 @@ export default class LocalHandler extends RemoteHandlerAbstract {
}
_writeFile(file, data, { flags }) {
if (Array.isArray(data)) {
return this.#addSyncStackTrace(writeBuffers, this.getFilePath(file), data, flags)
}
return this.#addSyncStackTrace(fs.writeFile, this.getFilePath(file), data, { flag: flags })
}
get _writesBufferArrays() {
return true
}
}

View File

@@ -303,7 +303,14 @@ export default class S3Handler extends RemoteHandlerAbstract {
}
async _unlink(path) {
await this.#s3.send(new DeleteObjectCommand(this.#createParams(path)))
try {
await this.#s3.send(new DeleteObjectCommand(this.#createParams(path)))
} catch (error) {
// Some providers answers 404 NoSuchKey where AWS answers 204
if (error.name !== 'NoSuchKey' && error.$metadata?.httpStatusCode !== 404) {
throw error
}
}
if (await this.#isNotEmptyDir(path)) {
const error = new Error(`EISDIR: illegal operation on a directory, unlink '${path}'`)
@@ -370,8 +377,20 @@ export default class S3Handler extends RemoteHandlerAbstract {
if (typeof file !== 'string') {
file = file.fd
}
const result = await this.#s3.send(new HeadObjectCommand(this.#createParams(file)))
return +result.ContentLength
try {
const result = await this.#s3.send(new HeadObjectCommand(this.#createParams(file)))
return +result.ContentLength
} catch (error) {
// a HEAD response has no body, so a missing object is reported as
// NotFound instead of the NoSuchKey the other methods get
if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
const enoentError = new Error(`ENOENT: no such file '${file}'`, { cause: error })
enoentError.code = 'ENOENT'
enoentError.path = file
throw enoentError
}
throw error
}
}
async _read(file, buffer, position = 0) {

View File

@@ -136,7 +136,7 @@ export abstract class RemoteHandlerAbstract {
abstract read(file: FileDescriptor, buffer: Buffer, position?: number): Promise<ReadResult>
abstract outputStream(path: string, input: Readable, options?: OutputStreamOptions): Promise<void>
abstract outputFile(file: string, data: string | Buffer, options?: OutputFileOptions): Promise<void>
abstract outputFile(file: string, data: string | Buffer | Buffer[], options?: OutputFileOptions): Promise<void>
abstract writeFile(file: string, data: string | Buffer, options?: WriteFileOptions): Promise<void>
abstract write(file: FileArg, buffer: Buffer, position?: number): Promise<void>

View File

@@ -16,13 +16,13 @@
"xo-immutable-remote": "./dist/protectRemotes.mjs"
},
"license": "AGPL-3.0-or-later",
"version": "3.0.7",
"version": "3.0.9",
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"@vates/async-each": "^1.0.3",
"@xen-orchestra/backups": "^0.76.1",
"@xen-orchestra/backups": "^0.78.0",
"@xen-orchestra/log": "^0.7.2",
"app-conf": "^3.0.0",
"execa": "^5.0.0",

View File

@@ -139,7 +139,13 @@ describe('protectRemotes/watchRemote', async () => {
await fs.writeFile(path.join(vmDir, `${BACKUP_DATE}.json`), '{}')
// The whole directory (and its contents) must become immutable.
// `chattr -R` flags the directory before recursing into its children, so
// waiting on the directory alone races with the files inside it.
await waitFor(() => Directory.isImmutable(vhdDir))
await waitFor(() => Directory.isImmutable(blocks))
await waitFor(() => File.isImmutable(bat))
await waitFor(() => File.isImmutable(header))
await waitFor(() => File.isImmutable(footer))
assert.strictEqual(await Directory.isImmutable(vhdDir), true, 'vhd dir should be immutable')
assert.strictEqual(await File.isImmutable(bat), true, 'bat should be immutable')

View File

@@ -1,12 +1,25 @@
# ChangeLog
## **next**
## **0.27.0** (2026-10-29)
- [New network] Choose between no NBD, secure NBD and insecure NBD (not encrypted) when creating a network (PR [#10540](https://github.com/vatesfr/xen-orchestra/pull/10540))
- [VM/System] Fix video RAM displayed in bytes instead of MiB (PR [#10486](https://github.com/vatesfr/xen-orchestra/pull/10486))
- [Treeview] Add a search bar and virtualize the tree (PR [#10414](https://github.com/vatesfr/xen-orchestra/pull/10414))
- [New/VM] Hide guest tools ISO SR in new VM ISO selector (PR [#10430](https://github.com/vatesfr/xen-orchestra/pull/10430))
- [Host] Add possibility to enable/disable a host (PR [#10337](https://github.com/vatesfr/xen-orchestra/pull/10337))
- [Host] Add possibility to restart a host toolstack (PR [#10480](https://github.com/vatesfr/xen-orchestra/pull/10480))
- [Host] Add possibility to detach a host (PR [#10520](https://github.com/vatesfr/xen-orchestra/pull/10520))
- [Side panels] Harmonize card titles across side panels (PR [#10289](https://github.com/vatesfr/xen-orchestra/pull/10289))
- [VM/New] Fix UEFI VMs booting in BIOS mode when created with a network install or without disk (PR [#10521](https://github.com/vatesfr/xen-orchestra/pull/10521))
- [Treeview] Press Ctrl+K (Cmd+K on macOS) to focus the treeview search (PR [#10492](https://github.com/vatesfr/xen-orchestra/pull/10492))
- [Pool] Fix connection, stats and consoles when overriding the pool master with `?master=`: use HTTPS by default instead of the protocol and port of the page (PR [#9955](https://github.com/vatesfr/xen-orchestra/pull/9955))
- [Pool/Host/VM] Add copy button for multiple information on Host/VM Dashboard and Pool/Host/VM System tabs (PR [#9976](https://github.com/vatesfr/xen-orchestra/pull/9976))
- [New/VM] Fix `MAP_DUPLICATE_KEY` error that could randomly prevent the VM creation (PR [#10552](https://github.com/vatesfr/xen-orchestra/pull/10552))
- [Host/Network] Add the possibility to forget a PIF (PR [#10239](https://github.com/vatesfr/xen-orchestra/pull/10239))
- [Lite] Display dates and times as `YYYY-MM-DD HH:mm:ss` (PR [#10547](https://github.com/vatesfr/xen-orchestra/pull/10547))
- [Pool/Hosts] Add a side panel displaying the selected host's details, and a search bar to filter the hosts table (PR [#10580](https://github.com/vatesfr/xen-orchestra/pull/10580))
- [VM/Host] Add console clipboard management (PR [#10625](https://github.com/vatesfr/xen-orchestra/pull/10625))
- [VM/Network] Add the possibility to edit a VIF (PR [#10505](https://github.com/vatesfr/xen-orchestra/pull/10505))
## **0.26.0** (2026-10-01)

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/lite",
"version": "0.26.0",
"version": "0.27.0",
"type": "module",
"scripts": {
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
@@ -27,13 +27,13 @@
"@types/file-saver": "^2.0.7",
"@types/lodash-es": "^4.17.12",
"@types/node": "^18.19.80",
"@vates/types": "^2.3.0",
"@vates/types": "^2.5.0",
"@vitejs/plugin-vue": "^6.0.0",
"@vue/tsconfig": "^0.9.0",
"@vueuse/core": "^14.0.0",
"@vueuse/math": "^14.0.0",
"@vueuse/shared": "^14.0.0",
"@xen-orchestra/web-core": "^0.63.1",
"@xen-orchestra/web-core": "^0.65.0",
"complex-matcher": "^1.1.1",
"d3-time-format": "^4.1.0",
"decorator-synchronized": "^0.6.0",

View File

@@ -7,7 +7,7 @@
{{ powerState.text }}
</template>
</VtsKeyValueRow>
<VtsKeyValueRow :label="t('ip-address')" :value="host.address" />
<VtsKeyValueRow :label="t('ip-address')" :value="host.address" copy />
<VtsKeyValueRow :label="t('started')" :value="isRunning ? relativeStartTime : undefined" />
<VtsKeyValueRow :label="t('master')">
<template #value>
@@ -27,19 +27,13 @@
</VtsKeyValueRow>
</VtsKeyValueList>
<VtsKeyValueList>
<VtsKeyValueRow :label="t('uuid')" :value="host.uuid" />
<VtsKeyValueRow :label="t('uuid')" :value="host.uuid" copy />
<VtsKeyValueRow :label="t('description')" :value="host.name_description" />
<VtsKeyValueRow :label="t('version')" :value="host.software_version.product_version" />
<VtsKeyValueRow
:label="t('hardware')"
:value="`${host.bios_strings['system-manufacturer']} (${host.bios_strings['system-product-name']})`"
/>
<VtsKeyValueRow :label="t('hardware')" :value="manufacturerInfo" />
</VtsKeyValueList>
<VtsKeyValueList>
<VtsKeyValueRow
:label="t('cores-with-sockets')"
:value="`${host.cpu_info.cpu_count} (${host.cpu_info.socket_count})`"
/>
<VtsKeyValueRow :label="t('cores-with-sockets')" :value="coreSocketInfo" />
<VtsKeyValueRow :label="t('ram')" :value="`${ram?.value} ${ram?.prefix}`" />
<VtsKeyValueRow :label="t('tags')">
<template #value>
@@ -54,10 +48,11 @@
<script lang="ts" setup>
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
import { useHostUtils } from '@/modules/host/composables/host-utils.composable.ts'
import { getHostCoreSocketInfo, getHostManufacturerInfo } from '@/modules/host/utils/host.util.ts'
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
import { useHostStore } from '@/stores/xen-api/host.store.ts'
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
import type { IconName } from '@core/icons'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsKeyValueList from '@core/components/key-value-list/VtsKeyValueList.vue'
import VtsKeyValueRow from '@core/components/key-value-row/VtsKeyValueRow.vue'
@@ -65,10 +60,8 @@ import VtsQuickInfoCard from '@core/components/quick-info-card/VtsQuickInfoCard.
import VtsTag from '@core/components/tag/VtsTag.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiTagsList from '@core/components/ui/tag/UiTagsList.vue'
import useRelativeTime from '@core/composables/relative-time.composable.ts'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { formatSizeRaw } from '@core/utils/size.util.ts'
import { parseDateTime } from '@core/utils/time.util.ts'
import { logicAnd } from '@vueuse/math'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -79,24 +72,17 @@ const { host } = defineProps<{
const { t } = useI18n()
const { isMasterHost, masterHost, isReady: isPoolReady } = usePoolStore().subscribe()
const { masterHost, isReady: isPoolReady } = usePoolStore().subscribe()
const { isReady: isHostReady } = useHostStore().subscribe()
const { isHostRunning, getHostMemory, isReady: isHostMetricsReady } = useHostMetricsStore().subscribe()
const { getHostMemory, isReady: isHostMetricsReady } = useHostMetricsStore().subscribe()
const { isMaster, isRunning, powerState, relativeStartTime } = useHostUtils(() => host)
const isReady = logicAnd(isPoolReady, isHostReady, isHostMetricsReady)
const isRunning = computed(() => isHostRunning(host))
const manufacturerInfo = computed(() => getHostManufacturerInfo(host))
const powerState = computed<{ text: string; icon: IconName }>(() => ({
text: isRunning.value ? t(`host:status:running`) : t('host:status:halted'),
icon: isRunning.value ? 'status:running-circle' : 'status:halted-circle',
}))
const date = computed(() => new Date(parseDateTime(Number(host.other_config.boot_time) * 1000)))
const relativeStartTime = useRelativeTime(date)
const isMaster = computed(() => isMasterHost(host.$ref))
const coreSocketInfo = computed(() => getHostCoreSocketInfo(host))
const ram = computed(() => {
const memory = getHostMemory(host)

View File

@@ -1,9 +1,17 @@
<template>
<VtsSidePanel :has-selection="!!pif" class="host-pif-side-panel" @close="emit('close')">
<template v-if="pif" #actions>
<VtsForgetButton
:busy="isForgettingPifs"
:disabled="!canForgetPifs"
:tooltip="!canForgetPifs && forgetPifsErrorMessage"
@click="forgetPifs()"
/>
</template>
<template v-if="pif" #default>
<!-- PIF -->
<UiPanelCard>
<VtsCardObjectTitle :id="pif.uuid" :label="isBond ? t('bond') : t('pif')" />
<UiPanelCardTitle :id="pif.uuid" size="medium" :label="isBond ? t('bond') : t('pif')" />
<div class="content">
<!-- NETWORK -->
<VtsCardRowKeyValue>
@@ -78,7 +86,7 @@
</UiPanelCard>
<!-- NETWORK INFORMATION -->
<UiPanelCard>
<UiCardTitle>{{ t('network-information') }}</UiCardTitle>
<UiPanelCardTitle size="medium" :label="t('network-information')" />
<div class="content">
<!-- IP ADDRESSES -->
<div v-if="ipAddresses.length">
@@ -183,7 +191,7 @@
</UiPanelCard>
<!-- PROPERTIES -->
<UiPanelCard>
<UiCardTitle>{{ t('properties') }}</UiCardTitle>
<UiPanelCardTitle size="medium" :label="t('properties')" />
<div class="content">
<!-- MTU -->
<VtsCardRowKeyValue>
@@ -226,19 +234,20 @@
<script setup lang="ts">
import type { XenApiPif } from '@/libs/xen-api/xen-api.types.ts'
import { usePifForget } from '@/modules/pif/composables/use-pif-forget.composable.ts'
import { useNetworkStore } from '@/stores/xen-api/network.store.ts'
import { usePifMetricsStore } from '@/stores/xen-api/pif-metrics.store.ts'
import { usePifStore } from '@/stores/xen-api/pif.store.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardObjectTitle from '@core/components/card-object-title/VtsCardObjectTitle.vue'
import VtsCopyAllMenuItem from '@core/components/copy-all-menu-item/VtsCopyAllMenuItem.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsForgetButton from '@core/components/forget-button/VtsForgetButton.vue'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsSidePanel from '@core/components/panel/VtsSidePanel.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import VtsTag from '@core/components/tag/VtsTag.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import UiTagsList from '@core/components/ui/tag/UiTagsList.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import humanFormat from 'human-format'
@@ -259,6 +268,10 @@ const { getBondsDevices, isBondMaster } = usePifStore().subscribe()
const { t } = useI18n()
const { forgetPifs, canForgetPifs, isForgettingPifs, forgetPifsErrorMessage } = usePifForget(() =>
pif !== undefined ? [pif] : []
)
const ipAddresses = computed(() => {
if (pif === undefined) {
return []

View File

@@ -26,6 +26,7 @@
<script lang="ts" setup>
import type { XenApiNetwork, XenApiPif } from '@/libs/xen-api/xen-api.types.ts'
import { usePifForget } from '@/modules/pif/composables/use-pif-forget.composable.ts'
import { useNetworkStore } from '@/stores/xen-api/network.store.ts'
import { usePifStore } from '@/stores/xen-api/pif.store.ts'
import VtsRow from '@core/components/table/VtsRow.vue'
@@ -111,7 +112,7 @@ function getManagementIcon(pif: XenApiPif) {
}
const { HeadCells, BodyCells } = usePifColumns({
exclude: ['actions'],
exclude: ['selectItem'],
body: (pif: XenApiPif) => {
const name = computed(() => getNetworkName(pif.network))
const status = computed(() => getPifStatus(pif))
@@ -120,6 +121,8 @@ const { HeadCells, BodyCells } = usePifColumns({
const ipMode = computed(() => getIpConfigurationMode(pif.ip_configuration_mode))
const rightIcon = computed(() => getManagementIcon(pif))
const { forgetPifs, canForgetPifs, isForgettingPifs, forgetPifsErrorMessage } = usePifForget(() => [pif])
return {
network: r => r({ label: name.value }),
device: r => r(pif.device, { rightIcon: rightIcon.value }),
@@ -128,7 +131,21 @@ const { HeadCells, BodyCells } = usePifColumns({
ip: r => r(ipAddresses.value),
mac: r => r(pif.MAC),
mode: r => r(ipMode.value),
selectItem: r => r(() => (selectedPifId.value = pif.uuid)),
actions: r =>
r({
onClick: () => (selectedPifId.value = pif.uuid),
actions: [
{
label: t('action:forget'),
icon: 'action:forget',
onClick: () => forgetPifs(),
busy: isForgettingPifs.value,
disabled: !canForgetPifs.value,
hint: forgetPifsErrorMessage.value,
accent: 'danger',
},
],
}),
}
},
})

View File

@@ -4,8 +4,8 @@
{{ t('general-information') }}
</UiTitle>
<VtsTabularKeyValueList>
<VtsTabularKeyValueRow :label="t('name')" :value="host.name_label" />
<VtsTabularKeyValueRow :label="t('uuid')" :value="host.uuid" />
<VtsTabularKeyValueRow :label="t('name')" :value="host.name_label" copy />
<VtsTabularKeyValueRow :label="t('uuid')" :value="host.uuid" copy />
<VtsTabularKeyValueRow :label="t('description')" :value="host.name_description" />
<VtsTabularKeyValueRow :label="t('tags')">
<template v-if="host.tags.length > 0" #value>
@@ -58,7 +58,7 @@
<script setup lang="ts">
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
import { useHostUtils } from '@/modules/host/composables/host-utils.composable.ts'
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsRelativeTime from '@core/components/relative-time/VtsRelativeTime.vue'
@@ -71,7 +71,6 @@ import UiLink from '@core/components/ui/link/UiLink.vue'
import UiTagsList from '@core/components/ui/tag/UiTagsList.vue'
import UiTitle from '@core/components/ui/title/UiTitle.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { host } = defineProps<{
@@ -80,9 +79,7 @@ const { host } = defineProps<{
const { t } = useI18n()
const { pool, isMasterHost, masterHost } = usePoolStore().subscribe()
const { isHostRunning } = useHostMetricsStore().subscribe()
const { pool, masterHost } = usePoolStore().subscribe()
const isMaster = computed(() => isMasterHost(host.$ref))
const isRunning = computed(() => isHostRunning(host))
const { isMaster, isRunning } = useHostUtils(() => host)
</script>

View File

@@ -4,19 +4,13 @@
{{ t('hardware-specifications') }}
</UiTitle>
<VtsTabularKeyValueList>
<VtsTabularKeyValueRow
:label="t('manufacturer-info')"
:value="`${host.bios_strings['system-manufacturer']} (${host.bios_strings['system-product-name']})`"
/>
<VtsTabularKeyValueRow :label="t('manufacturer-info')" :value="manufacturerInfo" />
<VtsTabularKeyValueRow
:label="t('bios-info')"
:value="`${host.bios_strings['bios-vendor']} (${host.bios_strings['bios-version']})`"
/>
<VtsTabularKeyValueRow :label="t('cpu-model')" :value="host.cpu_info.modelname" />
<VtsTabularKeyValueRow
:label="t('core-socket')"
:value="`${host.cpu_info.cpu_count} (${host.cpu_info.socket_count})`"
/>
<VtsTabularKeyValueRow :label="t('core-socket')" :value="coreSocketInfo" />
<VtsTabularKeyValueRow :label="t('gpus')">
<template v-if="isReady" #value>
<template v-if="devicesNames">
@@ -33,6 +27,7 @@
<script setup lang="ts">
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
import { getHostCoreSocketInfo, getHostManufacturerInfo } from '@/modules/host/utils/host.util.ts'
import { usePciStore } from '@/stores/xen-api/pci.store.ts'
import { usePgpuStore } from '@/stores/xen-api/pgpu.store.ts'
import VtsTabularKeyValueList from '@core/components/tabular-key-value-list/VtsTabularKeyValueList.vue'
@@ -41,6 +36,7 @@ import UiCard from '@core/components/ui/card/UiCard.vue'
import UiTitle from '@core/components/ui/title/UiTitle.vue'
import { logicAnd } from '@vueuse/math'
import { useArrayReduce } from '@vueuse/shared'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { host } = defineProps<{
@@ -54,6 +50,10 @@ const { getByOpaqueRef: getPciByOpaqueRef, isReady: isPciReady } = usePciStore()
const isReady = logicAnd(isPgpuReady, isPciReady)
const manufacturerInfo = computed(() => getHostManufacturerInfo(host))
const coreSocketInfo = computed(() => getHostCoreSocketInfo(host))
const devicesNames = useArrayReduce(
() => pGpusByHost.value.get(host.$ref) ?? [],
(acc, pGpu) => {

View File

@@ -4,9 +4,9 @@
{{ t('networking') }}
</UiTitle>
<VtsTabularKeyValueList>
<VtsTabularKeyValueRow :label="t('ip-address')" :value="host.address" />
<VtsTabularKeyValueRow :label="t('ip-address')" :value="host.address" copy />
<VtsTabularKeyValueRow :label="t('remote-syslog')" :value="host.logging.syslog_destination" />
<VtsTabularKeyValueRow :label="t('iscsi-iqn')" :value="host.iscsi_iqn" />
<VtsTabularKeyValueRow :label="t('iscsi-iqn')" :value="host.iscsi_iqn" copy />
<VtsTabularKeyValueRow :label="t('multi-pathing')">
<template #value>
<VtsStatus :status="host.multipathing" />

View File

@@ -17,13 +17,12 @@
<script setup lang="ts">
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
import { useHostUtils } from '@/modules/host/composables/host-utils.composable.ts'
import VtsRelativeTime from '@core/components/relative-time/VtsRelativeTime.vue'
import VtsTabularKeyValueList from '@core/components/tabular-key-value-list/VtsTabularKeyValueList.vue'
import VtsTabularKeyValueRow from '@core/components/tabular-key-value-row/VtsTabularKeyValueRow.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiTitle from '@core/components/ui/title/UiTitle.vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { host } = defineProps<{
@@ -32,7 +31,5 @@ const { host } = defineProps<{
const { t } = useI18n()
const { isHostRunning } = useHostMetricsStore().subscribe()
const isRunning = computed(() => isHostRunning(host))
const { isRunning } = useHostUtils(() => host)
</script>

View File

@@ -23,6 +23,7 @@
<script setup lang="ts">
import type { XenApiPif } from '@/libs/xen-api/xen-api.types.ts'
import { getHostState } from '@/modules/host/utils/host.util.ts'
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
import { useHostStore } from '@/stores/xen-api/host.store.ts'
import { usePifStore } from '@/stores/xen-api/pif.store.ts'
@@ -41,7 +42,7 @@ const { pif } = defineProps<{
const { t } = useI18n()
const { getByOpaqueRef } = useHostStore().subscribe()
const { isHostRunning } = useHostMetricsStore().subscribe()
const { getHostPowerState } = useHostMetricsStore().subscribe()
const { getPifStatus } = usePifStore().subscribe()
const router = useRouter()
@@ -57,7 +58,7 @@ const pifHost = computed(() => {
return {
label: host.name_label,
powerState: isHostRunning(host) ? 'running' : 'halted',
powerState: getHostState(host, getHostPowerState(host)),
redirect() {
router.push({
name: '/host/[uuid]/network',

View File

@@ -20,7 +20,7 @@
</template>
<template v-if="network" #default>
<UiPanelCard>
<VtsCardObjectTitle :id="network.uuid" :label="network.name_label" />
<UiPanelCardTitle :id="network.uuid" size="medium" :label="network.name_label" />
<div class="content">
<!-- DESCRIPTION -->
<VtsCardRowKeyValue truncate align-top>
@@ -66,10 +66,7 @@
</div>
</UiPanelCard>
<UiPanelCard v-if="pifsCount && pifsCount > 0">
<div class="typo-body-bold">
{{ t('pifs') }}
<UiCounter :value="pifsCount" variant="primary" size="small" accent="neutral" />
</div>
<UiPanelCardTitle size="medium" :label="t('pifs')" :counter="pifsCount" />
<table class="simple-table">
<thead>
<tr>
@@ -100,15 +97,14 @@ import type { XenApiNetwork } from '@/libs/xen-api/xen-api.types.ts'
import { useNetworkDelete } from '@/modules/network/composables/use-network-delete.composable.ts'
import { usePifStore } from '@/stores/xen-api/pif.store.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardObjectTitle from '@core/components/card-object-title/VtsCardObjectTitle.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsDeleteButton from '@core/components/delete-button/VtsDeleteButton.vue'
import MenuItem from '@core/components/menu/MenuItem.vue'
import MenuList from '@core/components/menu/MenuList.vue'
import VtsSidePanel from '@core/components/panel/VtsSidePanel.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import UiCounter from '@core/components/ui/counter/UiCounter.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { useClipboard } from '@vueuse/core'
import { computed } from 'vue'

View File

@@ -4,8 +4,8 @@
{{ t('general-information') }}
</UiTitle>
<VtsTabularKeyValueList>
<VtsTabularKeyValueRow :label="t('name')" :value="pool.name_label" />
<VtsTabularKeyValueRow :label="t('uuid')" :value="pool.uuid" />
<VtsTabularKeyValueRow :label="t('name')" :value="pool.name_label" copy />
<VtsTabularKeyValueRow :label="t('uuid')" :value="pool.uuid" copy />
<VtsTabularKeyValueRow :label="t('description')" :value="pool.name_description" />
<VtsTabularKeyValueRow :label="t('tags')">
<template v-if="pool.tags.length > 0" #value>

View File

@@ -62,8 +62,8 @@ const useTaskColumns = defineColumns(() => {
name: useTextColumn({ headerLabel: t('name') }),
host: useLinkColumn({ headerLabel: t('host') }),
progress: useProgressBarColumn({ headerLabel: t('progress') }),
started: useDateColumn({ headerLabel: t('started'), dateStyle: 'short', timeStyle: 'short' }),
estimatedEnd: useDateColumn({ headerLabel: t('estimated-end'), dateStyle: 'short', timeStyle: 'short' }),
started: useDateColumn({ headerLabel: t('started') }),
estimatedEnd: useDateColumn({ headerLabel: t('estimated-end') }),
}
})

View File

@@ -143,7 +143,7 @@ const state = useTableState({
})
const { HeadCells, BodyCells } = useVmColumns({
exclude: ['actions'],
exclude: ['actions', 'checkbox'],
body: (vm: XenApiVm) => {
const guestMetrics = computed(() => getGuestMetrics(vm.guest_metrics))
const vmMetrics = computed(() => getVmMetrics(vm.metrics))

View File

@@ -9,19 +9,19 @@
</span>
</template>
</VtsKeyValueRow>
<VtsKeyValueRow :label="t('ip-address')" :value="mainIpAddress" />
<VtsKeyValueRow :label="t('ip-address')" :value="mainIpAddress" copy />
<VtsKeyValueRow :label="t('created-on')" :value="installDateFormatted" />
<VtsKeyValueRow :label="t('started')" :value="relativeStartTime" />
</VtsKeyValueList>
<VtsKeyValueList>
<VtsKeyValueRow :label="t('uuid')" :value="vm.uuid" />
<VtsKeyValueRow :label="t('uuid')" :value="vm.uuid" copy />
<VtsKeyValueRow :label="t('host')">
<template #value>
<UiLink
v-if="host"
:to="`/host/${host.uuid}`"
size="medium"
:icon="`object:host:${hostPowerState}`"
:icon="hostIcon"
:is-primary="isPoolMaster"
:primary-tooltip="t('master')"
>
@@ -56,6 +56,7 @@
<script lang="ts" setup>
import { VM_POWER_STATE } from '@/libs/xen-api/xen-api.enums.ts'
import type { XenApiVm } from '@/libs/xen-api/xen-api.types.ts'
import { getHostIcon } from '@/modules/host/utils/host.util.ts'
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
import { useVmGuestMetricsStore } from '@/stores/xen-api/vm-guest-metrics.store.ts'
@@ -71,7 +72,7 @@ import UiTagsList from '@core/components/ui/tag/UiTagsList.vue'
import { useTimeAgo } from '@core/composables/locale-time-ago.composable.ts'
import { useMapper } from '@core/packages/mapper'
import { formatSizeRaw } from '@core/utils/size.util.ts'
import { parseDateTime } from '@core/utils/time.util.ts'
import { formatDate, parseDateTime } from '@core/utils/time.util.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -79,12 +80,12 @@ const { vm } = defineProps<{
vm: XenApiVm
}>()
const { t, locale } = useI18n()
const { t } = useI18n()
const { isReady, getVmHost } = useVmStore().subscribe()
const { getByOpaqueRef: getGuestMetricsByOpaqueRef } = useVmGuestMetricsStore().subscribe()
const { getByOpaqueRef: getMetricsByOpaqueRef } = useVmMetricsStore().subscribe()
const { isHostRunning } = useHostMetricsStore().subscribe()
const { getHostPowerState } = useHostMetricsStore().subscribe()
const { pool } = usePoolStore().subscribe()
const guestMetrics = computed(() => getGuestMetricsByOpaqueRef(vm.guest_metrics))
@@ -95,13 +96,7 @@ const host = computed(() => getVmHost(vm))
const isPoolMaster = computed(() => pool.value?.master === host.value?.$ref)
const hostPowerState = computed(() => {
if (host.value === undefined) {
return 'unknown'
}
return isHostRunning(host.value) ? 'running' : 'halted'
})
const hostIcon = computed(() => getHostIcon(host.value, getHostPowerState(host.value)))
const powerState = useMapper(
() => vm.power_state,
@@ -141,7 +136,7 @@ const installDateFormatted = computed(() => {
return t('unknown')
}
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'long' }).format(new Date(parseDateTime(installTime)))
return formatDate(installTime)
})
const ram = computed(() => formatSizeRaw(vm.memory_dynamic_max, 0))

View File

@@ -4,8 +4,8 @@
{{ t('general-information') }}
</UiTitle>
<VtsTabularKeyValueList>
<VtsTabularKeyValueRow :label="t('name')" :value="vm.name_label" />
<VtsTabularKeyValueRow :label="t('id')" :value="vm.uuid" />
<VtsTabularKeyValueRow :label="t('name')" :value="vm.name_label" copy />
<VtsTabularKeyValueRow :label="t('id')" :value="vm.uuid" copy />
<VtsTabularKeyValueRow :label="t('description')" :value="vm.name_description" />
<VtsTabularKeyValueRow :label="t('tags')">
<template v-if="vm.tags.length > 0" #value>

View File

@@ -1,7 +1,7 @@
import { fetchMissingHostPatches } from '@/libs/host.ts'
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
import type { XenApiPatch } from '@/types/xen-api.ts'
import { type Pausable, useTimeoutPoll, watchArray } from '@vueuse/core'
import { type Pausable, tryOnScopeDispose, useTimeoutPoll, watchArray } from '@vueuse/core'
import { computed, type MaybeRefOrGetter, reactive, toValue } from 'vue'
export type XenApiPatchWithHostRefs = XenApiPatch & { $hostRefs: Set<string> }
@@ -56,6 +56,8 @@ export const useHostPatches = (hosts: MaybeRefOrGetter<XenApiHost[]>) => {
{ immediate: true }
)
tryOnScopeDispose(() => configByHost.forEach(config => config.timeoutPoll.pause()))
const patches = computed(() => {
const records = new Map<string, XenApiPatchWithHostRefs>()

View File

@@ -1,5 +1,6 @@
import { ipToHostname } from '@/libs/utils.ts'
import { useHostStore } from '@/stores/xen-api/host.store.ts'
import { useXenApiStore } from '@/stores/xen-api.store.ts'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { reactiveComputed, whenever } from '@vueuse/core'
import { difference } from 'lodash-es'
@@ -8,11 +9,12 @@ import { ref, watch } from 'vue'
export const useUnreachableHosts = () => {
const { records: hosts } = useHostStore().subscribe()
const unreachableHostsUrls = ref<Set<string>>(new Set())
const { protocol } = new URL(useXenApiStore().getXapi().hostUrl)
watch(hosts, (nextHosts, previousHosts) => {
difference(nextHosts, previousHosts).forEach(host => {
const url = new URL('http://localhost')
url.protocol = window.location.protocol
url.protocol = protocol
url.hostname = ipToHostname(host.address)
fetch(url, { mode: 'no-cors' }).catch(() => unreachableHostsUrls.value.add(url.toString()))
})

View File

@@ -12,6 +12,8 @@ export function createNetworkOperations(xenApi: XenApi) {
nameDescription?: string
mtu?: number
nbd?: boolean
/** NBD in clear, only on a network dedicated to backups. Mutually exclusive with `nbd` */
insecureNbd?: boolean
}
type NetworkCreateParams = BaseNetworkCreateParams & {
@@ -34,18 +36,25 @@ export function createNetworkOperations(xenApi: XenApi) {
},
])
// nbd and insecure_nbd are incompatible purposes, at most one of them is added
const addNbdPurpose = async (networkRef: XenApiNetwork['$ref'], { nbd, insecureNbd }: BaseNetworkCreateParams) => {
if (nbd) {
await xenApi.call('network.add_purpose', [networkRef, 'nbd'])
} else if (insecureNbd) {
await xenApi.call('network.add_purpose', [networkRef, 'insecure_nbd'])
}
}
return {
create: async (params: NetworkCreateParams): Promise<XenApiNetwork['$ref']> => {
const { nameLabel, nameDescription = '', mtu = 1500, vlan = 0, pifRef, nbd } = params
const { nameLabel, nameDescription = '', mtu = 1500, vlan = 0, pifRef } = params
const networkRef = await createEmptyNetwork(nameLabel, nameDescription, mtu)
try {
await xenApi.call('pool.create_VLAN_from_PIF', [pifRef, networkRef, vlan])
if (nbd) {
await xenApi.call('network.add_purpose', [networkRef, 'nbd'])
}
await addNbdPurpose(networkRef, params)
} catch (error) {
await xenApi.call('network.destroy', [networkRef])
throw error
@@ -55,7 +64,7 @@ export function createNetworkOperations(xenApi: XenApi) {
},
createBonded: async (params: BondedNetworkCreateParams): Promise<XenApiNetwork['$ref']> => {
const { nameLabel, nameDescription = '', mtu = 1500, pifRefs, bondMode, nbd } = params
const { nameLabel, nameDescription = '', mtu = 1500, pifRefs, bondMode } = params
const networkRef = await createEmptyNetwork(nameLabel, nameDescription, mtu)
@@ -87,9 +96,7 @@ export function createNetworkOperations(xenApi: XenApi) {
)
)
if (nbd) {
await xenApi.call('network.add_purpose', [networkRef, 'nbd'])
}
await addNbdPurpose(networkRef, params)
} catch (error) {
await xenApi.call('network.destroy', [networkRef])
throw error
@@ -99,17 +106,15 @@ export function createNetworkOperations(xenApi: XenApi) {
},
createInternal: async (params: BaseNetworkCreateParams): Promise<XenApiNetwork['$ref']> => {
const { nameLabel, nameDescription = '', mtu = 1500, nbd } = params
const { nameLabel, nameDescription = '', mtu = 1500 } = params
const networkRef = await createEmptyNetwork(nameLabel, nameDescription, mtu)
if (nbd) {
try {
await xenApi.call('network.add_purpose', [networkRef, 'nbd'])
} catch (error) {
await xenApi.call('network.destroy', [networkRef])
throw error
}
try {
await addNbdPurpose(networkRef, params)
} catch (error) {
await xenApi.call('network.destroy', [networkRef])
throw error
}
return networkRef

View File

@@ -1,8 +1,30 @@
import type XenApi from '@/libs/xen-api/xen-api.ts'
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
import type { XenApiHost, XenApiPif } from '@/libs/xen-api/xen-api.types.ts'
import type { MaybeArray } from '@core/types/utility.type.ts'
import { toArray } from '@core/utils/to-array.utils.ts'
import { OPAQUE_REF } from '@vates/types'
export function createPifOperations(xenApi: XenApi) {
return {
scan: (hostRef: XenApiHost['$ref']) => xenApi.call('PIF.scan', [hostRef]),
forget: (pifs: MaybeArray<XenApiPif>) =>
Promise.all(
toArray(pifs).map(async pif => {
if (pif.VLAN_master_of !== OPAQUE_REF.EMPTY) {
return xenApi.call('VLAN.destroy', [pif.VLAN_master_of])
}
if (pif.bond_master_of.length > 0) {
return Promise.all(pif.bond_master_of.map(bondRef => xenApi.call('Bond.destroy', [bondRef])))
}
if (pif.tunnel_access_PIF_of.length > 0) {
await xenApi.call('PIF.unplug', [pif.$ref])
return Promise.all(pif.tunnel_access_PIF_of.map(tunnelRef => xenApi.call('tunnel.destroy', [tunnelRef])))
}
return xenApi.call('PIF.forget', [pif.$ref])
})
),
}
}

View File

@@ -0,0 +1,12 @@
import type XenApi from '@/libs/xen-api/xen-api.ts'
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
export function createPoolOperations(xenApi: XenApi) {
type HostRef = XenApiHost['$ref']
const eject = (hostRef: HostRef) => xenApi.call('pool.eject', [hostRef])
return {
eject,
}
}

View File

@@ -25,6 +25,14 @@ export function createVifOperations(xenApi: XenApi) {
qos_algorithm_type?: string
}
const setOtherConfig = (vifRefs: VifRefs, key: string, value: string) =>
Promise.all(
toArray(vifRefs).map(async vifRef => {
await xenApi.call('VIF.remove_from_other_config', [vifRef, key])
await xenApi.call('VIF.add_to_other_config', [vifRef, key, value])
})
)
return {
create: async (vifs: VifCreateParams[]) => {
const results: VifRefs = []
@@ -78,5 +86,34 @@ export function createVifOperations(xenApi: XenApi) {
plug: (vifRefs: VifRefs) => Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.plug', [vifRef]))),
unplug: (vifRefs: VifRefs) => Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.unplug', [vifRef]))),
unplugForce: (vifRefs: VifRefs) =>
Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.unplug_force', [vifRef]))),
move: (vifRefs: VifRefs, networkRef: NetworkRef) =>
Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.move', [vifRef, networkRef]))),
setIpv4Allowed: (vifRefs: VifRefs, ips: string[]) =>
Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.set_ipv4_allowed', [vifRef, ips]))),
setIpv6Allowed: (vifRefs: VifRefs, ips: string[]) =>
Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.set_ipv6_allowed', [vifRef, ips]))),
setLockingMode: (vifRefs: VifRefs, lockingMode: VIF_LOCKING_MODE) =>
Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.set_locking_mode', [vifRef, lockingMode]))),
setRateLimit: (vifRefs: VifRefs, kbps: number | null) =>
Promise.all(
toArray(vifRefs).map(async vifRef => {
await xenApi.call('VIF.set_qos_algorithm_type', [vifRef, kbps === null ? '' : 'ratelimit'])
await xenApi.call('VIF.remove_from_qos_algorithm_params', [vifRef, 'kbps'])
if (kbps !== null) {
await xenApi.call('VIF.add_to_qos_algorithm_params', [vifRef, 'kbps', String(kbps)])
}
})
),
setTxChecksumming: (vifRefs: VifRefs, enabled: boolean) => setOtherConfig(vifRefs, 'ethtool-tx', String(enabled)),
}
}

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