Compare commits

...

146 Commits

Author SHA1 Message Date
Florent BEAUCHAMP
49b28abd12 refactor(@xen-orchestra/backups): rename exportDeltaVm to exportIncrementalVm and importDeltaVM to importIncrementalVm 2023-05-22 10:53:10 +02:00
Florent BEAUCHAMP
1878a8cd44 refactor(@xen-orchestra/backups): _deltaVm => _incrementalVm 2023-05-22 10:49:02 +02:00
Florent BEAUCHAMP
ea24d7cf51 refactor(@xen-orchestra/backups) : rename replication writers to xapi writers 2023-05-22 10:49:02 +02:00
Florent BEAUCHAMP
a064d9ad33 refactor(@xen-orchestra/backups): rename backup writer to remote writer 2023-05-22 10:48:57 +02:00
Florent BEAUCHAMP
4562929ace refactor(@xen-orchestra/backups): split VmBackup in classes 2023-05-22 10:44:38 +02:00
Florent BEAUCHAMP
6e1c67e0fc refactor(@xen-orchestra/backups): split Backup Job in XapiVmBackupJob and MetadatasBackupJob 2023-05-22 10:44:18 +02:00
Florent BEAUCHAMP
375e47dc61 refactor(@xen-orchestra/backups): move Backup , VmBackup, writers and specific method to a private folder 2023-05-22 10:33:30 +02:00
Florent BEAUCHAMP
95d985b8a8 refactor(@xen-orchestra/backups): use a factory to instantiate backup job 2023-05-22 10:32:31 +02:00
Florent BEAUCHAMP
26354ac164 refactor(@xen-orchestra/backups): extract methods from Backup.js
getAdaptersByRemote , RemoteTimeoutError and runTasks
2023-05-22 10:32:21 +02:00
Florent BEAUCHAMP
79cfec3205 refactor(@xen-orchestra/backups): rename delta writers to incremental writers 2023-05-22 10:27:04 +02:00
Florent BEAUCHAMP
f482d4a14c refactor(@xen-orchestra/backups): _VMBackup rename delta to incremental 2023-05-22 10:27:04 +02:00
Julien Fontanet
77b166bb3b chore: update dev deps 2023-05-22 10:01:54 +02:00
Julien Fontanet
76bd54d7de chore: update dev deps 2023-05-17 14:48:41 +02:00
Julien Fontanet
684282f0a4 fix(mixins/Tasks): correctly serialize errors 2023-05-17 11:29:28 +02:00
Julien Fontanet
2459f46c19 feat(xo-cli rest): accept query string in path
Example:
```
xo-cli rest post vms/<uuid>/actions/snapshot?sync
```
2023-05-17 11:27:29 +02:00
Julien Fontanet
5f0466e4d8 feat: release 5.82.2 2023-05-17 10:05:11 +02:00
Gabriel Gunullu
3738edfa83 test(@xen-orchestra/fs): from Jest to test (#6820) 2023-05-17 09:54:51 +02:00
Julien Fontanet
769e27e2cb feat: technical release 2023-05-16 16:32:33 +02:00
Julien Fontanet
8ec5461338 feat(xo-server): 5.114.2 2023-05-16 16:31:54 +02:00
Julien Fontanet
4a2843cb67 feat(@xen-orchestra/proxy): 0.26.23 2023-05-16 16:31:33 +02:00
Julien Fontanet
a0e69a79ab feat(xen-api): 1.3.1 2023-05-16 16:30:54 +02:00
Roni Väyrynen
3da94f18df docs(installation): add findmnt command to sudoers config example (#6835) 2023-05-16 15:20:47 +02:00
Mathieu
17cb59b898 feat(xo-web/host-item): display warning for when HVM disabled (#6834) 2023-05-16 14:58:14 +02:00
Mathieu
315e5c9289 feat(xo-web/proxy): make proxy address editable (#6816) 2023-05-16 12:12:31 +02:00
Julien Fontanet
01ba10fedb fix(xen-api/putResource): really fix (302) redirection with non-stream body
Replaces the incorrect fix in 87e6f7fde

Introduced by ab96c549a

Fixes zammad#13375
Fixes zammad#13952
Fixes zammad#14001
2023-05-15 16:23:18 +02:00
Mathieu
13e7594560 fix(xo-web/SortedTable): handle pending state for collapsed actions (#6831) 2023-05-15 15:27:17 +02:00
Thierry Goettelmann
f9ac2ac84d feat(lite/tooltips): enhance and simplify tooltips (#6760)
- Removed the `disabled` option.
- The tooltip is now disabled when content is an empty string or `false`.
- If content is `true` or `undefined`, it will be extracted from element's `innerText`.
- Moved `v-tooltip` from `InfraHostItem` and `InfraVmItem` to `InfraItemLabel`.
2023-05-15 11:55:43 +02:00
Thierry Goettelmann
09cfac1111 feat(lite): enhance Component Story skeleton generator (#6753)
- Updated form to use our own components
- Added a warning for props whose type cannot be extracted
- Fixed setting name for scopes containing a dash
- Handled cases when a prop can be multiple types
- Better guess of prop type
- Remove `.widget()` for `.model()`
- Remove `.event('update:modelValue')` for `.model()`
2023-05-15 11:23:42 +02:00
Thierry Goettelmann
008f7a30fd feat(lite): add VM tab bar (#6766) 2023-05-15 11:15:52 +02:00
Thierry Goettelmann
ff65dbcba7 feat(lite): extract and update "unreachable hosts modal" (#6745)
Extraction of unreachable host modal to its own component + Move the subtitle to the description.

Refer to #6744 for final design.
2023-05-15 11:11:19 +02:00
ggunullu
264a0d1678 fix(@vates/nbd-client): add custom coverage threshold to tap test
By default, Tap require 100 % coverage of all lines, branches, functions and statements.
We enforce a custom threshold to match the current state of the state and avoid regression.

See https://github.com/vatesfr/xen-orchestra/actions/runs/4956232764/jobs/8866437368
2023-05-15 10:18:02 +02:00
ggunullu
7dcaf454ed fix(eslint): treat *.integ.js as test files
Introduced by 3f73138fc3
2023-05-15 10:18:02 +02:00
Julien Fontanet
17b2756291 feat: release 5.82.1 2023-05-12 16:47:21 +02:00
Julien Fontanet
57e48b5d34 feat: technical release 2023-05-12 15:40:38 +02:00
Julien Fontanet
57ed984e5a feat(xo-web): 5.117.1 2023-05-12 15:40:16 +02:00
Julien Fontanet
100122f388 feat(xo-server): 5.114.1 2023-05-12 15:39:36 +02:00
Julien Fontanet
12d4b3396e feat(@xen-orchestra/proxy): 0.26.22 2023-05-12 15:39:16 +02:00
Julien Fontanet
ab35c710cb feat(@xen-orchestra/backups): 0.36.1 2023-05-12 15:38:46 +02:00
Florent BEAUCHAMP
4bd5b38aeb fix(backups): fix health check task during CR (#6830)
Fixes https://xcp-ng.org/forum/post/62073

`healthCheck` is launched after `cleanVm`, therefore it should be closing the parent task, not `cleanVm`.
2023-05-12 10:45:32 +02:00
Julien Fontanet
836db1b807 fix(xo-web/new/network): correct type for vlan (#6829)
BREAKING CHANGE: API method `network.create` no longer accepts a `string` for `vlan` param.

Fixes https://xcp-ng.org/forum/post/62090

Either `number` or `undefined`, not an empty string.
2023-05-12 10:36:59 +02:00
Julien Fontanet
73d88cc5f1 fix(xo-server/vm.convertToTemplate): handle VBD_IS_EMPTY (#6808)
Fixes https://xcp-ng.org/forum/post/61653
2023-05-12 09:12:41 +02:00
Julien Fontanet
3def66d968 chore(xo-vmdk-to-vhd): move notes.md to docs/
So that it will be correctly ignored when publishing the package.
2023-05-12 09:10:00 +02:00
Gabriel Gunullu
3f73138fc3 fix(test-integration): run integration tests only in ci (#6826)
Fixes issues introduced by

- be6233f
- adc5e7d

After the switching from Jest to Tap/Test, those tests were no longer executed during the test-integration script.
2023-05-11 17:47:48 +02:00
Julien Fontanet
bfe621a21d feat: technical release 2023-05-11 14:35:15 +02:00
Julien Fontanet
32fa792eeb feat(xo-web): 5.117.0 2023-05-11 14:23:02 +02:00
Julien Fontanet
a833050fc2 feat(xo-server): 5.114.0 2023-05-11 14:17:40 +02:00
Julien Fontanet
e7e6294bc3 feat(xo-vmdk-to-vhd): 2.5.4 2023-05-11 14:09:23 +02:00
Julien Fontanet
7c71884e27 feat(@vates/task): 0.1.2 2023-05-11 14:03:57 +02:00
Florent BEAUCHAMP
3e822044f2 fix(xo-vmdk-to-vhd): wait for OVA stream to be written before reading more data (#6800) 2023-05-11 12:23:06 +02:00
Julien Fontanet
d457f5fca4 chore(xo-server): use Task.run() helper 2023-05-11 11:10:00 +02:00
Julien Fontanet
1837e01719 fix(xo-server): new Task() now expects data instead of name option
Introduced by 036f3f6bd
2023-05-11 11:08:31 +02:00
Julien Fontanet
f17f5abf0f fix(xo-server/pif.reconfigureIp): accepts empty strings for dns, gateway, ip and netmask params 2023-05-11 09:08:05 +02:00
Florent BEAUCHAMP
82c229c755 fix(xo-server): better handling of importing running VM from ESXi (#6825)
Fixes https://xcp-ng.org/forum/post/59879

Fixes `Cannot read properties of undefined (reading 'stream')` error message
2023-05-10 18:25:37 +02:00
Julien Fontanet
c7e3ba3184 feat(xo-web/plugins): names can be clicked to filter out other plugins 2023-05-10 17:40:11 +02:00
Thierry Goettelmann
470c9bb6c8 fix(lite): handle escape key on CollectionFilter and CollectionSorter modals (#6822)
UiModal `@close` event was not defined on `CollectionFilter` and `CollectionSorter` modals.
2023-05-10 14:44:30 +02:00
Thierry Goettelmann
bb3ab20b2a fix(lite): typo in component name (#6821) 2023-05-10 10:11:06 +02:00
Julien Fontanet
90ce1c4d1e test(task/combineEvents): initial unit tests 2023-05-09 15:16:41 +02:00
Julien Fontanet
5c436f3870 fix(task/combineEvents): defineProperty → defineProperties
Fixes zammad#14566
2023-05-09 15:12:12 +02:00
Mathieu
159339625d feat(xo-server/vm.create): add resourceSet tags to created VM (#6812) 2023-05-09 14:33:59 +02:00
Julien Fontanet
87e6f7fded fix(xen-api/putResource): fix (302) redirection with non-stream body
Fixes zammad#13375
Fixes zammad#13952
Fixes zammad#14001
2023-05-09 14:09:33 +02:00
Pierre Donias
fd2c7c2fc3 fix(CHANGELOG): fix version number (#6805) 2023-04-28 14:52:44 +02:00
Mathieu
7fc76c1df4 feat: release 5.82 (#6804) 2023-04-28 14:32:01 +02:00
Mathieu
f2758d036d feat: technical release (#6803) 2023-04-28 13:28:15 +02:00
Pierre Donias
ac670da793 fix(xo-web/host/smart reboot): XOA Premium only (#6801)
See #6795
2023-04-28 11:15:28 +02:00
Mathieu
c0465eb4d9 feat: technical release (#6799) 2023-04-27 15:12:42 +02:00
Gabriel Gunullu
cea55b03e5 feat(xo-web/kubernetes): add high availability option (#6794)
See xoa#117
2023-04-27 13:54:58 +02:00
Julien Fontanet
d78d802066 fix(xo-server/rest-api): list tasks in the root collection
Introduced by 9e60c5375
2023-04-27 09:20:12 +02:00
Florent BEAUCHAMP
a562c74492 feat(backups/health check): support custom checks via XenStore (#6784) 2023-04-27 09:02:00 +02:00
Julien Fontanet
d1f2e0a84b fix(task): fix start event and add unit tests
Introduced by 6ea671a43
2023-04-26 17:29:35 +02:00
Mathieu
49e2d128ad feat: technical release (#6796) 2023-04-26 15:45:14 +02:00
Pierre Donias
f587798fb0 feat(xo-web/host): Smart Reboot (#6795)
Fixes #6750
See https://xcp-ng.org/forum/topic/7136
See #6791

Suspend resident VMs, restart host and resume VMs
2023-04-26 11:37:24 +02:00
Florent BEAUCHAMP
3430ee743b feat(xapi/VDI_exportContent) : implement NBD block level retry (#6763) 2023-04-26 11:32:38 +02:00
Thierry Goettelmann
83299587b0 feat(lite): subscriptions to XAPI collections (#6697) 2023-04-25 17:11:57 +02:00
Julien Fontanet
7c0ecf9b06 feat(xo-server/rest-api): stream objects
- less memory usage on big collections
- starts streaming as soon as possible instead of waiting for all the objects
2023-04-25 16:46:02 +02:00
Gabriel Gunullu
abfd84d32c chore(xo-web/kubernetes): rename 'master' to 'control plane' (#6789)
See xoa#116
2023-04-25 16:26:58 +02:00
Mathieu
0583a978be feat(xo-web/VM): display creator email and template name (#6771)
See xoa-support #13064 & #13094
2023-04-25 16:19:07 +02:00
Florent BEAUCHAMP
75989cf92d fix(backups/restore): fix boolean/int cast for backups created on XS<7.1 (#6772) 2023-04-25 10:26:06 +02:00
Julien Fontanet
f1cc284b6f feat(xo-server/host.restart): suspendResidentVms param (#6791)
Related to #6750
2023-04-25 10:22:40 +02:00
Julien Fontanet
0444cf0b3b fix(backups): disable VHD stream validation by default
Introduced by 68b2c287e

Fixes https://xcp-ng.org/forum/post/61118

Current code is too heavy on the main thread, disable by default until a better solution is fixed.
2023-04-24 16:53:04 +02:00
Julien Fontanet
226f9ad964 feat(PULL_REQUEST_TEMPLATE): simpler and easier to use
Co-authored-by: Pierre Donias <pierre.donias@gmail.com>
2023-04-24 10:53:28 +02:00
Julien Fontanet
a956cb2ac9 feat(xo-server/xo.exportConfig): new compress=true param 2023-04-21 17:12:13 +02:00
Julien Fontanet
76a91cc5e9 feat(xo-cli): @=- can now read from stdin 2023-04-21 16:56:36 +02:00
Julien Fontanet
f012d126b9 fix(xo-cli --list-commands): params with default values are optional 2023-04-21 10:27:05 +02:00
Julien Fontanet
bae0b52893 fix(CHANGELOG.unreleased): release xen-api
Introduced by a24512cea
2023-04-21 10:19:17 +02:00
Julien Fontanet
a24512cea9 feat(xen-api): configurable transport
Related to zammad#14008

Possible value for the `transport` setting: `auto`, `json-rpc`, `xml-rpc`.
2023-04-20 17:36:32 +02:00
Gabriel Gunullu
84b75e8a58 fix(xo-web/kubernetes-recipe): add DNS config (#6678)
See xoa#114
2023-04-20 09:43:32 +02:00
Julien Fontanet
6e25b7a83a chore(backups/importDeltaVm): add suspend_VDI to warning when missing 2023-04-19 17:38:20 +02:00
Julien Fontanet
136718df7e fix(backups/importDeltaVm): handle suspend_VDI === 'OpaqueRef:NULL'
Fixes https://xcp-ng.org/forum/post/61169

Introduced by 4d55c5ae4
2023-04-19 17:38:20 +02:00
Julien Fontanet
d48ef1f810 feat(xo-cli): subcommands to interact with the REST API 2023-04-19 16:23:53 +02:00
Julien Fontanet
9e60c53750 feat(xo-server/rest-api): tasks integration 2023-04-19 16:23:53 +02:00
Julien Fontanet
f3c5e817a3 feat(mixins/Tasks): db, log consolidation, abortion & watch 2023-04-19 16:23:53 +02:00
Julien Fontanet
60f6e54da1 feat(task): combineEvents 2023-04-19 16:23:53 +02:00
Julien Fontanet
f5a59caca2 docs(task): README improvements 2023-04-19 16:23:53 +02:00
Julien Fontanet
6ea671a434 feat(task): remove aborting status
It's unnecessary and it complicates tests.
2023-04-19 16:23:53 +02:00
Julien Fontanet
036f3f6bd0 feat(task): any data can be attached to the task
Previously, only `name` was accepted.
2023-04-19 16:23:53 +02:00
Julien Fontanet
12552a1391 feat(task): put async storage in the global scope 2023-04-19 16:23:53 +02:00
Julien Fontanet
e9b658b60d chore(task): remove unnecessary prop 2023-04-19 16:23:53 +02:00
Florent BEAUCHAMP
15f69a19f5 fix(xo-server/OVA import): revert to tar-stream@2 (#6779)
Fixes https://xcp-ng.org/forum/post/60648

Introduced by 656d13d

tar-stream@3 uses different kind of streams (`streamx`) which breaks `stream.read(size)`.
2023-04-18 14:38:18 +02:00
Julien Fontanet
54d885fa9c Revert "feat(backups/writeVhd): check file has expected size (#6703)" (#6773)
This reverts commit 77b1adae37.
2023-04-17 13:53:54 +02:00
Mathieu
11cc299940 feat(xo-web/dashboard/health): add free space column for storage state (#6778)
See xoa-support#13538
2023-04-14 11:10:38 +02:00
Thierry Goettelmann
091b0a3ef3 fix(lite/component-stories): replace markdown-it with marked + ComponentStory.vue fix (#6733) 2023-04-13 09:02:22 +02:00
Julien Fontanet
87874a4b81 chore(xo-server/addApiMethod(s)): compatible signature with proxy.api.addMethod(s)
First step toward mutualization, it allows shared mixins to easily add API methods in both apps.
2023-04-12 16:10:12 +02:00
Julien Fontanet
86aaa50946 feat(mixins/Hooks): start* listeners can return teardown functions 2023-04-12 15:59:38 +02:00
Julien Fontanet
68b2c287eb feat(backups): validate VHD streams (#6770) 2023-04-12 12:05:55 +02:00
Thierry Goettelmann
61f1316c42 feat(lite): revamp UiTable (#6742) 2023-04-12 10:19:43 +02:00
Julien Fontanet
afadc8f95a fix(read-chunk): better not enough data error message 2023-04-08 10:53:05 +02:00
Julien Fontanet
955ef6806c fix(read-chunk): handle already errored streams 2023-04-08 10:52:28 +02:00
Julien Fontanet
4d55c5ae48 fix(backups): restore VM with memory as suspended (#6774)
Fixes #5061
2023-04-07 16:02:52 +02:00
Julien Fontanet
5c6ae1912b feat(xo-server/vm.convertToTemplate): eject removable medias (#6769)
Fixes #6752
2023-04-07 10:12:02 +02:00
Gabriel Gunullu
083483645e fix(xo-server-usage-report): change dataset size (#6723)
Fixes zammad#12215
2023-04-06 17:33:24 +02:00
Thierry Goettelmann
c077e9a699 fix(lite): loading status (#6767) 2023-04-06 10:10:58 +02:00
Mathieu
280b60808f fix(xo-web/backup): maxExportRate invalid parameters (#6768)
Introduced by dc6a13962f
2023-04-05 15:53:40 +02:00
aknisly
eb9608b893 docs(backups): clarify retention strategies (#6743) 2023-04-05 12:32:57 +02:00
Julien Fontanet
a29f3d67ea chore: update dev deps 2023-04-05 11:40:25 +02:00
Julien Fontanet
6b150dc8a8 docs: fix custom container syntax 2023-04-05 11:24:07 +02:00
Julien Fontanet
8f55884602 chore: format with Prettier 2023-04-05 11:22:10 +02:00
Alex
2fdba2eb0f docs(users): clarify GitHub setup (#6751) 2023-04-05 11:12:53 +02:00
Mathieu
7e4bd30f04 fix(lite/treeview): fix host highlighting (#6747)
From a VM view, after selecting an host, the previously selected host remains highlighted
2023-04-04 12:16:52 +02:00
Julien Fontanet
eb8f098aaf feat(xo-server/vm.create): store creation date/template/user (#6731) 2023-04-04 09:34:04 +02:00
Mathieu
5237fdd387 feat(lite): handle involuntary console disconnection (#6706) 2023-04-03 10:42:56 +02:00
Julien Fontanet
8a07b7a3db fix(CHANGELOG): REST API changes are features, not fixes 2023-03-31 17:06:12 +02:00
Gabriel Gunullu
a41037833c feat: release 5.81 (#6765) 2023-03-31 16:36:18 +02:00
Gabriel Gunullu
6a780d94a3 feat: technical release (#6764) 2023-03-31 13:35:31 +02:00
Julien Fontanet
506ef0b44f fix(vhd-lib/createVhdStreamWithLength): fix empty VHD handling
Follow-up of 4e9477f34

Introduced by c26a7a3e5
2023-03-31 10:02:44 +02:00
Florent BEAUCHAMP
a4d1d41b6a refactor(xapi/VDI_exportContent): can export from NBD (#6716) 2023-03-30 18:21:39 +02:00
Julien Fontanet
4e9477f34a fix(vhd-lib/createVhdStreamWithLength): don't call readChunkStrict with 0
Introduced by c26a7a3e5
2023-03-30 14:34:10 +02:00
Julien Fontanet
43b6285437 feat(read-chunk): add skip() and skipStrict() 2023-03-30 12:14:59 +02:00
Julien Fontanet
c26a7a3e51 chore(read-chunk): assert size >= 0 2023-03-30 12:14:59 +02:00
Julien Fontanet
93eb42785d chore(read-chunk): assert size <= 1GiB 2023-03-30 12:14:59 +02:00
Julien Fontanet
02bb622e92 chore(read-chunk): improve JSDoc 2023-03-30 12:14:59 +02:00
Thierry Goettelmann
b873c147a6 chore(lite): upgrade human-format and remove its TS declaration file (#6762) 2023-03-30 10:36:39 +02:00
Gabriel Gunullu
5e7fb7a881 feat: technical release (#6759) 2023-03-29 16:51:38 +02:00
Mathieu
97790313eb feat(xo-web/host): pro support icon at host level (#6633)
- Host is not XCP-ng: no icon
- Host doesn't have license or is part of a pool that contains at least one host that doesn't have a license: orange life buoy
- Host has license and all hosts in its pool has license (=supported host): green life buoy
- Host doesn't have license and at least one host in its pool has a license: red triangle
2023-03-29 15:54:15 +02:00
Pierre Donias
954b29cb61 fix(xo-web/New VM): do not send empty MAC addresses to API (#6758)
See https://github.com/vatesfr/xen-orchestra/issues/6748#issuecomment-1484699540
2023-03-29 15:36:50 +02:00
Mathieu
dc6a13962f feat(xo-web/backup): expose maxExportRate from UI (#6737) 2023-03-29 14:48:53 +02:00
Florent BEAUCHAMP
23da202790 fix(xo-web): relax pattern for bucket (#6757)
bucket from AWS follow some naming convention, but other providers are more relaxed.
removing the regex check will allow the user to tests connecting and see the back-end specific error message
2023-03-29 14:45:26 +02:00
rajaa-b
f237101b4a feat(xo-web/import): ability to import multiple VMs from VMware (#6718)
See #6708
2023-03-29 14:35:45 +02:00
Julien Fontanet
8a99326a76 feat(xo-cli): 0.17.0 2023-03-29 12:02:18 +02:00
Gabriel Gunullu
8c95974e65 fix(xo-server-perf-alert): skip special SRs which are always full (#6755) 2023-03-29 11:18:52 +02:00
Pierre Donias
3f7454efad feat(lite/settings): add XCP-ng info (#6715) 2023-03-29 10:35:37 +02:00
Thierry Goettelmann
e5c890e29b feat(lite/stories): first stories for components (#6616) 2023-03-29 10:34:26 +02:00
Florent BEAUCHAMP
53e0f17c55 feat(xo-server): import multiple from ESXi (#6708) 2023-03-28 19:29:34 +02:00
Julien Fontanet
34f6be868e feat(backups,xo-web): store SR name label in logs
Similar to bbf92be65 & 92a00465e
2023-03-28 18:30:47 +02:00
Julien Fontanet
c84b899276 feat(xo-web/backup/log): display VM name if available
Fixes part of zammad#13551
2023-03-28 18:30:44 +02:00
Pierre Donias
266a26fa31 feat(xo-web): show Suse icon when distro name is opensuse-microos (#6746)
See https://xcp-ng.org/forum/topic/6965
2023-03-28 15:19:19 +02:00
Julien Fontanet
bbf92be652 feat(backups/Backup): add VM name label to log 2023-03-28 10:52:13 +02:00
331 changed files with 9604 additions and 5060 deletions

View File

@@ -28,7 +28,7 @@ module.exports = {
},
},
{
files: ['*.{spec,test}.{,c,m}js'],
files: ['*.{integ,spec,test}.{,c,m}js'],
rules: {
'n/no-unpublished-require': 'off',
'n/no-unpublished-import': 'off',

32
@vates/diff/.USAGE.md Normal file
View File

@@ -0,0 +1,32 @@
```js
import diff from '@vates/diff'
diff('foo bar baz', 'Foo qux')
// → [ 0, 'F', 4, 'qux', 7, '' ]
//
// Differences of the second string from the first one:
// - at position 0, it contains `F`
// - at position 4, it contains `qux`
// - at position 7, it ends
diff('Foo qux', 'foo bar baz')
// → [ 0, 'f', 4, 'bar', 7, ' baz' ]
//
// Differences of the second string from the first one:
// - at position 0, it contains f`
// - at position 4, it contains `bar`
// - at position 7, it contains `baz`
// works with all collections that supports
// - `.length`
// - `collection[index]`
// - `.slice(start, end)`
//
// which includes:
// - arrays
// - strings
// - `Buffer`
// - `TypedArray`
diff([0, 1, 2], [3, 4])
// → [ 0, [ 3, 4 ], 2, [] ]
```

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

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

65
@vates/diff/README.md Normal file
View File

@@ -0,0 +1,65 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/diff
[![Package Version](https://badgen.net/npm/v/@vates/diff)](https://npmjs.org/package/@vates/diff) ![License](https://badgen.net/npm/license/@vates/diff) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/diff)](https://bundlephobia.com/result?p=@vates/diff) [![Node compatibility](https://badgen.net/npm/node/@vates/diff)](https://npmjs.org/package/@vates/diff)
> Computes differences between two arrays, buffers or strings
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/diff):
```sh
npm install --save @vates/diff
```
## Usage
```js
import diff from '@vates/diff'
diff('foo bar baz', 'Foo qux')
// → [ 0, 'F', 4, 'qux', 7, '' ]
//
// Differences of the second string from the first one:
// - at position 0, it contains `F`
// - at position 4, it contains `qux`
// - at position 7, it ends
diff('Foo qux', 'foo bar baz')
// → [ 0, 'f', 4, 'bar', 7, ' baz' ]
//
// Differences of the second string from the first one:
// - at position 0, it contains f`
// - at position 4, it contains `bar`
// - at position 7, it contains `baz`
// works with all collections that supports
// - `.length`
// - `collection[index]`
// - `.slice(start, end)`
//
// which includes:
// - arrays
// - strings
// - `Buffer`
// - `TypedArray`
diff([0, 1, 2], [3, 4])
// → [ 0, [ 3, 4 ], 2, [] ]
```
## Contributions
Contributions are _very_ welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
you've encountered;
- fork and create a pull request.
## License
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)

37
@vates/diff/index.js Normal file
View File

@@ -0,0 +1,37 @@
'use strict'
/**
* Compare two data arrays, buffers or strings and invoke the provided callback function for each difference.
*
* @template {Array|Buffer|string} T
* @param {Array|Buffer|string} data1 - The first data array or buffer to compare.
* @param {T} data2 - The second data array or buffer to compare.
* @param {(index: number, diff: T) => void} [cb] - The callback function to invoke for each difference. If not provided, an array of differences will be returned.
* @returns {Array<number|T>|undefined} - An array of differences if no callback is provided, otherwise undefined.
*/
module.exports = function diff(data1, data2, cb) {
let result
if (cb === undefined) {
result = []
cb = result.push.bind(result)
}
const n1 = data1.length
const n2 = data2.length
const n = Math.min(n1, n2)
for (let i = 0; i < n; ++i) {
if (data1[i] !== data2[i]) {
let j = i + 1
while (j < n && data1[j] !== data2[j]) {
++j
}
cb(i, data2.slice(i, j))
i = j
}
}
if (n1 !== n2) {
cb(n, n1 < n2 ? data2.slice(n) : data2.slice(0, 0))
}
return result
}

51
@vates/diff/index.test.js Normal file
View File

@@ -0,0 +1,51 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('test')
const diff = require('./index.js')
test('data of equal length', function () {
const data1 = 'foo bar baz'
const data2 = 'baz bar foo'
assert.deepEqual(diff(data1, data2), [0, 'baz', 8, 'foo'])
})
test('data1 is longer', function () {
const data1 = 'foo bar'
const data2 = 'foo'
assert.deepEqual(diff(data1, data2), [3, ''])
})
test('data2 is longer', function () {
const data1 = 'foo'
const data2 = 'foo bar'
assert.deepEqual(diff(data1, data2), [3, ' bar'])
})
test('with arrays', function () {
const data1 = 'foo bar baz'.split('')
const data2 = 'baz bar foo'.split('')
assert.deepEqual(diff(data1, data2), [0, 'baz'.split(''), 8, 'foo'.split('')])
})
test('with buffers', function () {
const data1 = Buffer.from('foo bar baz')
const data2 = Buffer.from('baz bar foo')
assert.deepEqual(diff(data1, data2), [0, Buffer.from('baz'), 8, Buffer.from('foo')])
})
test('cb param', function () {
const data1 = 'foo bar baz'
const data2 = 'baz bar foo'
const calls = []
const cb = (...args) => calls.push(args)
diff(data1, data2, cb)
assert.deepEqual(calls, [
[0, 'baz'],
[8, 'foo'],
])
})

36
@vates/diff/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"private": false,
"name": "@vates/diff",
"description": "Computes differences between two arrays, buffers or strings",
"keywords": [
"array",
"binary",
"buffer",
"diff",
"differences",
"string"
],
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/diff",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/diff",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.0",
"engines": {
"node": ">=8.10"
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node--test"
},
"devDependencies": {
"test": "^3.3.0"
}
}

View File

@@ -21,7 +21,7 @@
"fuse-native": "^2.2.6",
"lru-cache": "^7.14.0",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.2.1"
"vhd-lib": "^4.4.0"
},
"scripts": {
"postversion": "npm publish --access public"

View File

@@ -18,8 +18,11 @@ const {
OPTS_MAGIC,
NBD_CMD_DISC,
} = require('./constants.js')
const { fromCallback } = require('promise-toolbox')
const { fromCallback, pRetry, pDelay, pTimeout } = require('promise-toolbox')
const { readChunkStrict } = require('@vates/read-chunk')
const { createLogger } = require('@xen-orchestra/log')
const { warn } = createLogger('vates:nbd-client')
// documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
@@ -32,18 +35,34 @@ module.exports = class NbdClient {
#exportName
#exportSize
#waitBeforeReconnect
#readAhead
#readBlockRetries
#reconnectRetry
#connectTimeout
// AFAIK, there is no guaranty the server answers in the same order as the queries
// so we handle a backlog of command waiting for response and handle concurrency manually
#waitingForResponse // there is already a listenner waiting for a response
#nextCommandQueryId = BigInt(0)
#commandQueryBacklog // map of command waiting for an response queryId => { size/*in byte*/, resolve, reject}
#connected = false
constructor({ address, port = NBD_DEFAULT_PORT, exportname, cert }) {
#reconnectingPromise
constructor(
{ address, port = NBD_DEFAULT_PORT, exportname, cert },
{ connectTimeout = 6e4, waitBeforeReconnect = 1e3, readAhead = 10, readBlockRetries = 5, reconnectRetry = 5 } = {}
) {
this.#serverAddress = address
this.#serverPort = port
this.#exportName = exportname
this.#serverCert = cert
this.#waitBeforeReconnect = waitBeforeReconnect
this.#readAhead = readAhead
this.#readBlockRetries = readBlockRetries
this.#reconnectRetry = reconnectRetry
this.#connectTimeout = connectTimeout
}
get exportSize() {
@@ -78,24 +97,55 @@ module.exports = class NbdClient {
})
}
async connect() {
// first we connect to the serve without tls, and then we upgrade the connection
async #connect() {
// first we connect to the server without tls, and then we upgrade the connection
// to tls during the handshake
await this.#unsecureConnect()
await this.#handshake()
this.#connected = true
// reset internal state if we reconnected a nbd client
this.#commandQueryBacklog = new Map()
this.#waitingForResponse = false
}
async connect() {
return pTimeout.call(this.#connect(), this.#connectTimeout)
}
async disconnect() {
if (!this.#connected) {
return
}
const buffer = Buffer.alloc(28)
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
buffer.writeInt16BE(0, 4) // no command flags for a disconnect
buffer.writeInt16BE(NBD_CMD_DISC, 6) // we want to disconnect from nbd server
await this.#write(buffer)
await this.#serverSocket.destroy()
this.#serverSocket = undefined
this.#connected = false
}
#clearReconnectPromise = () => {
this.#reconnectingPromise = undefined
}
async #reconnect() {
await this.disconnect().catch(() => {})
await pDelay(this.#waitBeforeReconnect) // need to let the xapi clean things on its side
await this.connect()
}
async reconnect() {
// we need to ensure reconnections do not occur in parallel
if (this.#reconnectingPromise === undefined) {
this.#reconnectingPromise = pRetry(() => this.#reconnect(), {
tries: this.#reconnectRetry,
})
this.#reconnectingPromise.then(this.#clearReconnectPromise, this.#clearReconnectPromise)
}
return this.#reconnectingPromise
}
// we can use individual read/write from the socket here since there is no concurrency
@@ -173,7 +223,6 @@ module.exports = class NbdClient {
this.#commandQueryBacklog.forEach(({ reject }) => {
reject(error)
})
await this.disconnect()
}
async #readBlockResponse() {
@@ -181,7 +230,6 @@ module.exports = class NbdClient {
if (this.#waitingForResponse) {
return
}
try {
this.#waitingForResponse = true
const magic = await this.#readInt32()
@@ -206,7 +254,8 @@ module.exports = class NbdClient {
query.resolve(data)
this.#waitingForResponse = false
if (this.#commandQueryBacklog.size > 0) {
await this.#readBlockResponse()
// it doesn't throw directly but will throw all relevant promise on failure
this.#readBlockResponse()
}
} catch (error) {
// reject all the promises
@@ -217,6 +266,11 @@ module.exports = class NbdClient {
}
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
// we don't want to add anything in backlog while reconnecting
if (this.#reconnectingPromise) {
await this.#reconnectingPromise
}
const queryId = this.#nextCommandQueryId
this.#nextCommandQueryId++
@@ -231,19 +285,67 @@ module.exports = class NbdClient {
buffer.writeInt32BE(size, 24)
return new Promise((resolve, reject) => {
function decoratedReject(error) {
error.index = index
error.size = size
reject(error)
}
// this will handle one block response, but it can be another block
// since server does not guaranty to handle query in order
this.#commandQueryBacklog.set(queryId, {
size,
resolve,
reject,
reject: decoratedReject,
})
// really send the command to the server
this.#write(buffer).catch(reject)
this.#write(buffer).catch(decoratedReject)
// #readBlockResponse never throws directly
// but if it fails it will reject all the promises in the backlog
this.#readBlockResponse()
})
}
async *readBlocks(indexGenerator) {
// default : read all blocks
if (indexGenerator === undefined) {
const exportSize = this.#exportSize
const chunkSize = 2 * 1024 * 1024
indexGenerator = function* () {
const nbBlocks = Math.ceil(exportSize / chunkSize)
for (let index = 0; index < nbBlocks; index++) {
yield { index, size: chunkSize }
}
}
}
const readAhead = []
const readAheadMaxLength = this.#readAhead
const makeReadBlockPromise = (index, size) => {
const promise = pRetry(() => this.readBlock(index, size), {
tries: this.#readBlockRetries,
onRetry: async err => {
warn('will retry reading block ', index, err)
await this.reconnect()
},
})
// error is handled during unshift
promise.catch(() => {})
return promise
}
// read all blocks, but try to keep readAheadMaxLength promise waiting ahead
for (const { index, size } of indexGenerator()) {
// stack readAheadMaxLength promises before starting to handle the results
if (readAhead.length === readAheadMaxLength) {
// any error will stop reading blocks
yield readAhead.shift()
}
readAhead.push(makeReadBlockPromise(index, size))
}
while (readAhead.length > 0) {
yield readAhead.shift()
}
}
}

View File

@@ -13,16 +13,17 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "1.0.1",
"version": "1.2.0",
"engines": {
"node": ">=14.0"
},
"dependencies": {
"@vates/async-each": "^1.0.0",
"@vates/read-chunk": "^1.0.1",
"@vates/read-chunk": "^1.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"promise-toolbox": "^0.21.0",
"xen-api": "^1.2.7"
"xen-api": "^1.3.1"
},
"devDependencies": {
"tap": "^16.3.0",
@@ -30,6 +31,6 @@
},
"scripts": {
"postversion": "npm publish --access public",
"test-integration": "tap *.spec.js"
"test-integration": "tap --lines 70 --functions 36 --branches 54 --statements 69 *.integ.js"
}
}

View File

@@ -24,3 +24,25 @@ import { readChunkStrict } from '@vates/read-chunk'
const chunk = await readChunkStrict(stream, 1024)
```
### `skip(stream, size)`
Skips a given number of bytes from a stream.
Returns the number of bytes actually skipped, which may be less than the requested size if the stream has ended.
```js
import { skip } from '@vates/read-chunk'
const bytesSkipped = await skip(stream, 2 * 1024 * 1024 * 1024)
```
### `skipStrict(stream, size)`
Skips a given number of bytes from a stream and throws if the stream ended before enough stream has been skipped.
```js
import { skipStrict } from '@vates/read-chunk'
await skipStrict(stream, 2 * 1024 * 1024 * 1024)
```

View File

@@ -43,6 +43,28 @@ import { readChunkStrict } from '@vates/read-chunk'
const chunk = await readChunkStrict(stream, 1024)
```
### `skip(stream, size)`
Skips a given number of bytes from a stream.
Returns the number of bytes actually skipped, which may be less than the requested size if the stream has ended.
```js
import { skip } from '@vates/read-chunk'
const bytesSkipped = await skip(stream, 2 * 1024 * 1024 * 1024)
```
### `skipStrict(stream, size)`
Skips a given number of bytes from a stream and throws if the stream ended before enough stream has been skipped.
```js
import { skipStrict } from '@vates/read-chunk'
await skipStrict(stream, 2 * 1024 * 1024 * 1024)
```
## Contributions
Contributions are _very_ welcomed, either on the documentation or on

View File

@@ -1,18 +1,36 @@
'use strict'
const assert = require('assert')
/**
* Read a chunk of data from a stream.
*
* The returned promise is rejected if there is an error while reading the stream.
*
* For streams in object mode, the returned promise resolves to a single object read from the stream.
*
* For streams in binary mode, the returned promise resolves to a Buffer or a string if an encoding has been specified using the `stream.setEncoding()` method.
*
* If `size` bytes are not available to be read, `null` will be returned *unless* the stream has ended, in which case all of the data remaining will be returned.
*
* @param {Readable} stream - A readable stream to read from.
* @param {number} size - The number of bytes to read.
* @returns {Promise<Buffer|null>} - A Promise that resolves to a Buffer of up to size bytes if available, or null if end of stream is reached. The Promise is rejected if there is an error while reading from the stream.
* @param {number} [size] - The number of bytes to read for binary streams (ignored for object streams).
* @returns {Promise<Buffer|string|unknown|null>} - A Promise that resolves to the read chunk if available, or null if end of stream is reached.
*/
const readChunk = (stream, size) =>
stream.closed || stream.readableEnded
stream.errored != null
? Promise.reject(stream.errored)
: stream.closed || stream.readableEnded
? Promise.resolve(null)
: size === 0
? Promise.resolve(Buffer.alloc(0))
: new Promise((resolve, reject) => {
if (size !== undefined) {
assert(size > 0)
// per Node documentation:
// > The size argument must be less than or equal to 1 GiB.
assert(size < 1073741824)
}
function onEnd() {
resolve(null)
removeListeners()
@@ -43,9 +61,17 @@ exports.readChunk = readChunk
/**
* Read a chunk of data from a stream.
*
* The returned promise is rejected if there is an error while reading the stream.
*
* For streams in object mode, the returned promise resolves to a single object read from the stream.
*
* For streams in binary mode, the returned promise resolves to a Buffer or a string if an encoding has been specified using the `stream.setEncoding()` method.
*
* If `size` bytes are not available to be read, the returned promise is rejected.
*
* @param {Readable} stream - A readable stream to read from.
* @param {number} size - The number of bytes to read.
* @returns {Promise<Buffer>} - A Promise that resolves to a Buffer of size bytes. The Promise is rejected if there is an error while reading from the stream.
* @param {number} [size] - The number of bytes to read for binary streams (ignored for object streams).
* @returns {Promise<Buffer|string|unknown>} - A Promise that resolves to the read chunk.
*/
exports.readChunkStrict = async function readChunkStrict(stream, size) {
const chunk = await readChunk(stream, size)
@@ -54,7 +80,7 @@ exports.readChunkStrict = async function readChunkStrict(stream, size) {
}
if (size !== undefined && chunk.length !== size) {
const error = new Error('stream has ended with not enough data')
const error = new Error(`stream has ended with not enough data (actual: ${chunk.length}, expected: ${size})`)
Object.defineProperties(error, {
chunk: {
value: chunk,
@@ -65,3 +91,69 @@ exports.readChunkStrict = async function readChunkStrict(stream, size) {
return chunk
}
/**
* Skips a given number of bytes from a readable stream.
*
* @param {Readable} stream - A readable stream to skip bytes from.
* @param {number} size - The number of bytes to skip.
* @returns {Promise<number>} A Promise that resolves to the number of bytes actually skipped. If the end of the stream is reached before all bytes are skipped, the Promise resolves to the number of bytes that were skipped before the end of the stream was reached. The Promise is rejected if there is an error while reading from the stream.
*/
async function skip(stream, size) {
return stream.errored != null
? Promise.reject(stream.errored)
: size === 0 || stream.closed || stream.readableEnded
? Promise.resolve(0)
: new Promise((resolve, reject) => {
let left = size
function onEnd() {
resolve(size - left)
removeListeners()
}
function onError(error) {
reject(error)
removeListeners()
}
function onReadable() {
const data = stream.read()
left -= data === null ? 0 : data.length
if (left > 0) {
// continue to read
} else {
// if more than wanted has been read, push back the rest
if (left < 0) {
stream.unshift(data.slice(left))
}
resolve(size)
removeListeners()
}
}
function removeListeners() {
stream.removeListener('end', onEnd)
stream.removeListener('error', onError)
stream.removeListener('readable', onReadable)
}
stream.on('end', onEnd)
stream.on('error', onError)
stream.on('readable', onReadable)
onReadable()
})
}
exports.skip = skip
/**
* Skips a given number of bytes from a stream.
*
* @param {Readable} stream - A readable stream to skip bytes from.
* @param {number} size - The number of bytes to skip.
* @returns {Promise<void>} - A Promise that resolves when the exact number of bytes have been skipped. The Promise is rejected if there is an error while reading from the stream or the stream ends before the exact number of bytes have been skipped.
*/
exports.skipStrict = async function skipStrict(stream, size) {
const bytesSkipped = await skip(stream, size)
if (bytesSkipped !== size) {
const error = new Error(`stream has ended with not enough data (actual: ${bytesSkipped}, expected: ${size})`)
error.bytesSkipped = bytesSkipped
throw error
}
}

View File

@@ -5,12 +5,58 @@ const assert = require('node:assert').strict
const { Readable } = require('stream')
const { readChunk, readChunkStrict } = require('./')
const { readChunk, readChunkStrict, skip, skipStrict } = require('./')
const makeStream = it => Readable.from(it, { objectMode: false })
makeStream.obj = Readable.from
const rejectionOf = promise =>
promise.then(
value => {
throw value
},
error => error
)
const makeErrorTests = fn => {
it('rejects if the stream errors', async () => {
const error = new Error()
const stream = makeStream([])
const pError = rejectionOf(fn(stream, 10))
stream.destroy(error)
assert.strict(await pError, error)
})
// only supported for Node >= 18
if (process.versions.node.split('.')[0] >= 18) {
it('rejects if the stream has already errored', async () => {
const error = new Error()
const stream = makeStream([])
await new Promise(resolve => {
stream.once('error', resolve).destroy(error)
})
assert.strict(await rejectionOf(fn(stream, 10)), error)
})
}
}
describe('readChunk', () => {
it('rejects if size is less than or equal to 0', async () => {
const error = await rejectionOf(readChunk(makeStream([]), 0))
assert.strictEqual(error.code, 'ERR_ASSERTION')
})
it('rejects if size is greater than or equal to 1 GiB', async () => {
const error = await rejectionOf(readChunk(makeStream([]), 1024 * 1024 * 1024))
assert.strictEqual(error.code, 'ERR_ASSERTION')
})
makeErrorTests(readChunk)
it('returns null if stream is empty', async () => {
assert.strictEqual(await readChunk(makeStream([])), null)
})
@@ -38,10 +84,6 @@ describe('readChunk', () => {
it('returns less data if stream ends', async () => {
assert.deepEqual(await readChunk(makeStream(['foo', 'bar']), 10), Buffer.from('foobar'))
})
it('returns an empty buffer if the specified size is 0', async () => {
assert.deepEqual(await readChunk(makeStream(['foo', 'bar']), 0), Buffer.alloc(0))
})
})
describe('with object stream', () => {
@@ -52,14 +94,6 @@ describe('readChunk', () => {
})
})
const rejectionOf = promise =>
promise.then(
value => {
throw value
},
error => error
)
describe('readChunkStrict', function () {
it('throws if stream is empty', async () => {
const error = await rejectionOf(readChunkStrict(makeStream([])))
@@ -71,7 +105,43 @@ describe('readChunkStrict', function () {
it('throws if stream ends with not enough data', async () => {
const error = await rejectionOf(readChunkStrict(makeStream(['foo', 'bar']), 10))
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data')
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 6, expected: 10)')
assert.deepEqual(error.chunk, Buffer.from('foobar'))
})
})
describe('skip', function () {
makeErrorTests(skip)
it('returns 0 if size is 0', async () => {
assert.strictEqual(await skip(makeStream(['foo']), 0), 0)
})
it('returns 0 if the stream is already ended', async () => {
const stream = await makeStream([])
await readChunk(stream)
assert.strictEqual(await skip(stream, 10), 0)
})
it('skips a number of bytes', async () => {
const stream = makeStream('foo bar')
assert.strictEqual(await skip(stream, 4), 4)
assert.deepEqual(await readChunk(stream, 4), Buffer.from('bar'))
})
it('returns less size if stream ends', async () => {
assert.deepEqual(await skip(makeStream('foo bar'), 10), 7)
})
})
describe('skipStrict', function () {
it('throws if stream ends with not enough data', async () => {
const error = await rejectionOf(skipStrict(makeStream('foo bar'), 10))
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 7, expected: 10)')
assert.deepEqual(error.bytesSkipped, 7)
})
})

View File

@@ -19,7 +19,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "1.0.1",
"version": "1.1.1",
"engines": {
"node": ">=8.10"
},

View File

@@ -0,0 +1,42 @@
```js
import StreamReader from '@vates/stream-reader'
const reader = new StreamReader(stream)
```
### `.read([size])`
- returns the next available chunk of data
- like `stream.read()`, a number of bytes can be specified
- returns with less data than expected if stream has ended
- returns `null` if the stream has ended and no data has been read
```js
const chunk = await reader.read(512)
```
### `.readStrict([size])`
Similar behavior to `readChunk` but throws if the stream ended before the requested data could be read.
```js
const chunk = await reader.readStrict(512)
```
### `.skip(size)`
Skips a given number of bytes from a stream.
Returns the number of bytes actually skipped, which may be less than the requested size if the stream has ended.
```js
const bytesSkipped = await reader.skip(2 * 1024 * 1024 * 1024)
```
### `.skipStrict(size)`
Skips a given number of bytes from a stream and throws if the stream ended before enough stream has been skipped.
```js
await reader.skipStrict(2 * 1024 * 1024 * 1024)
```

View File

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

View File

@@ -0,0 +1,75 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/stream-reader
[![Package Version](https://badgen.net/npm/v/@vates/stream-reader)](https://npmjs.org/package/@vates/stream-reader) ![License](https://badgen.net/npm/license/@vates/stream-reader) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/stream-reader)](https://bundlephobia.com/result?p=@vates/stream-reader) [![Node compatibility](https://badgen.net/npm/node/@vates/stream-reader)](https://npmjs.org/package/@vates/stream-reader)
> Efficiently reads and skips chunks of a given size in a stream
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/stream-reader):
```sh
npm install --save @vates/stream-reader
```
## Usage
```js
import StreamReader from '@vates/stream-reader'
const reader = new StreamReader(stream)
```
### `.read([size])`
- returns the next available chunk of data
- like `stream.read()`, a number of bytes can be specified
- returns with less data than expected if stream has ended
- returns `null` if the stream has ended and no data has been read
```js
const chunk = await reader.read(512)
```
### `.readStrict([size])`
Similar behavior to `readChunk` but throws if the stream ended before the requested data could be read.
```js
const chunk = await reader.readStrict(512)
```
### `.skip(size)`
Skips a given number of bytes from a stream.
Returns the number of bytes actually skipped, which may be less than the requested size if the stream has ended.
```js
const bytesSkipped = await reader.skip(2 * 1024 * 1024 * 1024)
```
### `.skipStrict(size)`
Skips a given number of bytes from a stream and throws if the stream ended before enough stream has been skipped.
```js
await reader.skipStrict(2 * 1024 * 1024 * 1024)
```
## Contributions
Contributions are _very_ welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
you've encountered;
- fork and create a pull request.
## License
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)

View File

@@ -0,0 +1,123 @@
'use strict'
const assert = require('node:assert')
const { finished, Readable } = require('node:stream')
const noop = Function.prototype
// Inspired by https://github.com/nodejs/node/blob/85705a47958c9ae5dbaa1f57456db19bdefdc494/lib/internal/streams/readable.js#L1107
class StreamReader {
#ended = false
#error
#executor = resolve => {
this.#resolve = resolve
}
#stream
#resolve = noop
constructor(stream) {
stream = typeof stream.pipe === 'function' ? stream : Readable.from(stream)
this.#stream = stream
stream.on('readable', () => this.#resolve())
finished(stream, { writable: false }, error => {
this.#error = error
this.#ended = true
this.#resolve()
})
}
async read(size) {
if (size !== undefined) {
assert(size > 0)
}
do {
if (this.#ended) {
if (this.#error) {
throw this.#error
}
return null
}
const value = this.#stream.read(size)
if (value !== null) {
return value
}
await new Promise(this.#executor)
} while (true)
}
async readStrict(size) {
const chunk = await this.read(size)
if (chunk === null) {
throw new Error('stream has ended without data')
}
if (size !== undefined && chunk.length !== size) {
const error = new Error(`stream has ended with not enough data (actual: ${chunk.length}, expected: ${size})`)
Object.defineProperties(error, {
chunk: {
value: chunk,
},
})
throw error
}
return chunk
}
async skip(size) {
if (size === 0) {
return size
}
let toSkip = size
do {
if (this.#ended) {
if (this.#error) {
throw this.#error
}
return size - toSkip
}
const data = this.#stream.read()
if (data !== null) {
toSkip -= data === null ? 0 : data.length
if (toSkip > 0) {
// continue to read
} else {
// if more than wanted has been read, push back the rest
if (toSkip < 0) {
this.#stream.unshift(data.slice(toSkip))
}
return size
}
}
await new Promise(this.#executor)
} while (true)
}
async skipStrict(size) {
const bytesSkipped = await this.skip(size)
if (bytesSkipped !== size) {
const error = new Error(`stream has ended with not enough data (actual: ${bytesSkipped}, expected: ${size})`)
error.bytesSkipped = bytesSkipped
throw error
}
}
}
StreamReader.prototype[Symbol.asyncIterator] = async function* asyncIterator() {
let chunk
while ((chunk = await this.read()) !== null) {
yield chunk
}
}
module.exports = StreamReader

View File

@@ -0,0 +1,141 @@
'use strict'
const { describe, it } = require('test')
const assert = require('node:assert').strict
const { Readable } = require('stream')
const StreamReader = require('./index.js')
const makeStream = it => Readable.from(it, { objectMode: false })
makeStream.obj = Readable.from
const rejectionOf = promise =>
promise.then(
value => {
throw value
},
error => error
)
const makeErrorTests = method => {
it('rejects if the stream errors', async () => {
const error = new Error()
const stream = makeStream([])
const pError = rejectionOf(new StreamReader(stream)[method](10))
stream.destroy(error)
assert.strict(await pError, error)
})
it('rejects if the stream has already errored', async () => {
const error = new Error()
const stream = makeStream([])
await new Promise(resolve => {
stream.once('error', resolve).destroy(error)
})
assert.strict(await rejectionOf(new StreamReader(stream)[method](10)), error)
})
}
describe('read()', () => {
it('rejects if size is less than or equal to 0', async () => {
const error = await rejectionOf(new StreamReader(makeStream([])).read(0))
assert.strictEqual(error.code, 'ERR_ASSERTION')
})
it('returns null if stream is empty', async () => {
assert.strictEqual(await new StreamReader(makeStream([])).read(), null)
})
makeErrorTests('read')
it('returns null if the stream is already ended', async () => {
const reader = new StreamReader(makeStream([]))
await reader.read()
assert.strictEqual(await reader.read(), null)
})
describe('with binary stream', () => {
it('returns the first chunk of data', async () => {
assert.deepEqual(await new StreamReader(makeStream(['foo', 'bar'])).read(), Buffer.from('foo'))
})
it('returns a chunk of the specified size (smaller than first)', async () => {
assert.deepEqual(await new StreamReader(makeStream(['foo', 'bar'])).read(2), Buffer.from('fo'))
})
it('returns a chunk of the specified size (larger than first)', async () => {
assert.deepEqual(await new StreamReader(makeStream(['foo', 'bar'])).read(4), Buffer.from('foob'))
})
it('returns less data if stream ends', async () => {
assert.deepEqual(await new StreamReader(makeStream(['foo', 'bar'])).read(10), Buffer.from('foobar'))
})
})
describe('with object stream', () => {
it('returns the first chunk of data verbatim', async () => {
const chunks = [{}, {}]
assert.strictEqual(await new StreamReader(makeStream.obj(chunks)).read(), chunks[0])
})
})
})
describe('readStrict()', function () {
it('throws if stream is empty', async () => {
const error = await rejectionOf(new StreamReader(makeStream([])).readStrict())
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended without data')
assert.strictEqual(error.chunk, undefined)
})
it('throws if stream ends with not enough data', async () => {
const error = await rejectionOf(new StreamReader(makeStream(['foo', 'bar'])).readStrict(10))
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 6, expected: 10)')
assert.deepEqual(error.chunk, Buffer.from('foobar'))
})
})
describe('skip()', function () {
makeErrorTests('skip')
it('returns 0 if size is 0', async () => {
assert.strictEqual(await new StreamReader(makeStream(['foo'])).skip(0), 0)
})
it('returns 0 if the stream is already ended', async () => {
const reader = new StreamReader(makeStream([]))
await reader.read()
assert.strictEqual(await reader.skip(10), 0)
})
it('skips a number of bytes', async () => {
const reader = new StreamReader(makeStream('foo bar'))
assert.strictEqual(await reader.skip(4), 4)
assert.deepEqual(await reader.read(4), Buffer.from('bar'))
})
it('returns less size if stream ends', async () => {
assert.deepEqual(await new StreamReader(makeStream('foo bar')).skip(10), 7)
})
})
describe('skipStrict()', function () {
it('throws if stream ends with not enough data', async () => {
const error = await rejectionOf(new StreamReader(makeStream('foo bar')).skipStrict(10))
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 7, expected: 10)')
assert.deepEqual(error.bytesSkipped, 7)
})
})

View File

@@ -0,0 +1,39 @@
{
"private": false,
"name": "@vates/stream-reader",
"description": "Efficiently reads and skips chunks of a given size in a stream",
"keywords": [
"async",
"chunk",
"data",
"node",
"promise",
"read",
"reader",
"skip",
"stream"
],
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/stream-reader",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/stream-reader",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.0",
"engines": {
"node": ">=10"
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node--test"
},
"devDependencies": {
"test": "^3.3.0"
}
}

View File

@@ -2,7 +2,12 @@
import { Task } from '@vates/task'
const task = new Task({
name: 'my task',
// data in this object will be sent along the *start* event
//
// property names should be chosen as not to clash with properties used by `Task` or `combineEvents`
data: {
name: 'my task',
},
// if defined, a new detached task is created
//
@@ -25,8 +30,19 @@ const task = new Task({
// this field is settable once before being observed
task.id
// contains the current status of the task
//
// possible statuses are:
// - pending
// - success
// - failure
// - aborted
task.status
await task.abort()
// Triggers the abort signal associated to the task.
//
// This simply requests the task to abort, it will be up to the task to handle or not this signal.
task.abort(reason)
// if fn rejects, the task will be marked as failed
const result = await task.runInside(fn)
@@ -34,7 +50,11 @@ const result = await task.runInside(fn)
// if fn rejects, the task will be marked as failed
// if fn resolves, the task will be marked as succeeded
const result = await task.run(fn)
```
Inside a task:
```js
// the abort signal of the current task if any, otherwise is `undefined`
Task.abortSignal
@@ -52,3 +72,43 @@ Task.warning(message, data)
// - progress
Task.set(property, value)
```
### `combineEvents`
Create a consolidated log from individual events.
It can be used directly as an `onProgress` callback:
```js
import { makeOnProgress } from '@vates/task/combineEvents'
const onProgress = makeOnProgress({
// This function is called each time a root task starts.
//
// It will be called for as many times as there are tasks created with this `onProgress` function.
onRootTaskStart(taskLog) {
// `taskLog` is an object reflecting the state of this task and all its subtasks,
// and will be mutated in real-time to reflect the changes of the task.
},
// This function is called each time a root task ends.
onRootTaskEnd(taskLog) {},
// This function is called each time a root task or a subtask is updated.
//
// `taskLog.$root` can be used to uncondionally access the root task.
onTaskUpdate(taskLog) {},
})
Task.run({ data: { name: 'my task' }, onProgress }, asyncFn)
```
It can also be fed event logs directly:
```js
import { makeOnProgress } from '@vates/task/combineEvents'
const onProgress = makeOnProgress({ onRootTaskStart, onRootTaskEnd, onTaskUpdate })
eventLogs.forEach(onProgress)
```

View File

@@ -18,7 +18,12 @@ npm install --save @vates/task
import { Task } from '@vates/task'
const task = new Task({
name: 'my task',
// data in this object will be sent along the *start* event
//
// property names should be chosen as not to clash with properties used by `Task` or `combineEvents`
data: {
name: 'my task',
},
// if defined, a new detached task is created
//
@@ -41,8 +46,19 @@ const task = new Task({
// this field is settable once before being observed
task.id
// contains the current status of the task
//
// possible statuses are:
// - pending
// - success
// - failure
// - aborted
task.status
await task.abort()
// Triggers the abort signal associated to the task.
//
// This simply requests the task to abort, it will be up to the task to handle or not this signal.
task.abort(reason)
// if fn rejects, the task will be marked as failed
const result = await task.runInside(fn)
@@ -50,7 +66,11 @@ const result = await task.runInside(fn)
// if fn rejects, the task will be marked as failed
// if fn resolves, the task will be marked as succeeded
const result = await task.run(fn)
```
Inside a task:
```js
// the abort signal of the current task if any, otherwise is `undefined`
Task.abortSignal
@@ -69,6 +89,46 @@ Task.warning(message, data)
Task.set(property, value)
```
### `combineEvents`
Create a consolidated log from individual events.
It can be used directly as an `onProgress` callback:
```js
import { makeOnProgress } from '@vates/task/combineEvents'
const onProgress = makeOnProgress({
// This function is called each time a root task starts.
//
// It will be called for as many times as there are tasks created with this `onProgress` function.
onRootTaskStart(taskLog) {
// `taskLog` is an object reflecting the state of this task and all its subtasks,
// and will be mutated in real-time to reflect the changes of the task.
},
// This function is called each time a root task ends.
onRootTaskEnd(taskLog) {},
// This function is called each time a root task or a subtask is updated.
//
// `taskLog.$root` can be used to uncondionally access the root task.
onTaskUpdate(taskLog) {},
})
Task.run({ data: { name: 'my task' }, onProgress }, asyncFn)
```
It can also be fed event logs directly:
```js
import { makeOnProgress } from '@vates/task/combineEvents'
const onProgress = makeOnProgress({ onRootTaskStart, onRootTaskEnd, onTaskUpdate })
eventLogs.forEach(onProgress)
```
## Contributions
Contributions are _very_ welcomed, either on the documentation or on

View File

@@ -0,0 +1,77 @@
'use strict'
const assert = require('node:assert').strict
const noop = Function.prototype
function omit(source, keys, target = { __proto__: null }) {
for (const key of Object.keys(source)) {
if (!keys.has(key)) {
target[key] = source[key]
}
}
return target
}
const IGNORED_START_PROPS = new Set([
'end',
'infos',
'properties',
'result',
'status',
'tasks',
'timestamp',
'type',
'warnings',
])
exports.makeOnProgress = function ({ onRootTaskEnd = noop, onRootTaskStart = noop, onTaskUpdate = noop }) {
const taskLogs = new Map()
return function onProgress(event) {
const { id, type } = event
let taskLog
if (type === 'start') {
taskLog = omit(event, IGNORED_START_PROPS)
taskLog.start = event.timestamp
taskLog.status = 'pending'
taskLogs.set(id, taskLog)
const { parentId } = event
if (parentId === undefined) {
Object.defineProperty(taskLog, '$root', { value: taskLog })
// start of a root task
onRootTaskStart(taskLog)
} else {
// start of a subtask
const parent = taskLogs.get(parentId)
assert.notEqual(parent, undefined)
// inject a (non-enumerable) reference to the parent and the root task
Object.defineProperties(taskLog, { $parent: { value: parent }, $root: { value: parent.$root } })
;(parent.tasks ?? (parent.tasks = [])).push(taskLog)
}
} else {
taskLog = taskLogs.get(id)
assert.notEqual(taskLog, undefined)
if (type === 'info' || type === 'warning') {
const key = type + 's'
const { data, message } = event
;(taskLog[key] ?? (taskLog[key] = [])).push({ data, message })
} else if (type === 'property') {
;(taskLog.properties ?? (taskLog.properties = { __proto__: null }))[event.name] = event.value
} else if (type === 'end') {
taskLog.end = event.timestamp
taskLog.result = event.result
taskLog.status = event.status
}
if (type === 'end' && taskLog.$root === taskLog) {
onRootTaskEnd(taskLog)
}
}
onTaskUpdate(taskLog)
}
}

View File

@@ -0,0 +1,67 @@
'use strict'
const assert = require('node:assert').strict
const { describe, it } = require('test')
const { makeOnProgress } = require('./combineEvents.js')
const { Task } = require('./index.js')
describe('makeOnProgress()', function () {
it('works', async function () {
const events = []
let log
const task = new Task({
data: { name: 'task' },
onProgress: makeOnProgress({
onRootTaskStart(log_) {
assert.equal(log, undefined)
log = log_
events.push('onRootTaskStart')
},
onRootTaskEnd(log_) {
assert.equal(log_, log)
events.push('onRootTaskEnd')
},
onTaskUpdate(log_) {
assert.equal(log_.$root, log)
events.push('onTaskUpdate')
},
}),
})
assert.equal(events.length, 0)
await task.run(async () => {
assert.equal(events[0], 'onRootTaskStart')
assert.equal(events[1], 'onTaskUpdate')
assert.equal(log.name, 'task')
Task.set('progress', 0)
assert.equal(events[2], 'onTaskUpdate')
assert.equal(log.properties.progress, 0)
Task.info('foo', {})
assert.equal(events[3], 'onTaskUpdate')
assert.deepEqual(log.infos, [{ data: {}, message: 'foo' }])
await Task.run({ data: { name: 'subtask' } }, () => {
assert.equal(events[4], 'onTaskUpdate')
assert.equal(log.tasks[0].name, 'subtask')
Task.warning('bar', {})
assert.equal(events[5], 'onTaskUpdate')
assert.deepEqual(log.tasks[0].warnings, [{ data: {}, message: 'bar' }])
})
assert.equal(events[6], 'onTaskUpdate')
assert.equal(log.tasks[0].status, 'success')
Task.set('progress', 100)
assert.equal(events[7], 'onTaskUpdate')
assert.equal(log.properties.progress, 100)
})
assert.equal(events[8], 'onRootTaskEnd')
assert.equal(events[9], 'onTaskUpdate')
assert.equal(log.status, 'success')
})
})

View File

@@ -11,13 +11,15 @@ function define(object, property, value) {
const noop = Function.prototype
const ABORTED = 'aborted'
const ABORTING = 'aborting'
const FAILURE = 'failure'
const PENDING = 'pending'
const SUCCESS = 'success'
exports.STATUS = { ABORTED, ABORTING, FAILURE, PENDING, SUCCESS }
exports.STATUS = { ABORTED, FAILURE, PENDING, SUCCESS }
// stored in the global context so that various versions of the library can interact.
const asyncStorageKey = '@vates/task@0'
const asyncStorage = global[asyncStorageKey] ?? (global[asyncStorageKey] = new AsyncLocalStorage())
const asyncStorage = new AsyncLocalStorage()
const getTask = () => asyncStorage.getStore()
exports.Task = class Task {
@@ -66,7 +68,6 @@ exports.Task = class Task {
#abortController = new AbortController()
#onProgress
#parent
get id() {
return (this.id = Math.random().toString(36).slice(2))
@@ -82,16 +83,14 @@ exports.Task = class Task {
return this.#status
}
constructor({ name, onProgress }) {
this.#startData = { name }
constructor({ data = {}, onProgress } = {}) {
this.#startData = data
if (onProgress !== undefined) {
this.#onProgress = onProgress
} else {
const parent = getTask()
if (parent !== undefined) {
this.#parent = parent
const { signal } = parent.#abortController
signal.addEventListener('abort', () => {
this.#abortController.abort(signal.reason)
@@ -106,8 +105,12 @@ exports.Task = class Task {
const { signal } = this.#abortController
signal.addEventListener('abort', () => {
if (this.status === PENDING) {
this.#status = this.#running ? ABORTING : ABORTED
if (this.status === PENDING && !this.#running) {
this.#maybeStart()
const status = ABORTED
this.#status = status
this.#emit('end', { result: signal.reason, status })
}
})
}
@@ -123,14 +126,12 @@ exports.Task = class Task {
this.#onProgress(data)
}
#handleMaybeAbortion(result) {
if (this.status === ABORTING) {
this.#status = ABORTED
this.#emit('end', { status: ABORTED, result })
return true
#maybeStart() {
const startData = this.#startData
if (startData !== undefined) {
this.#startData = undefined
this.#emit('start', startData)
}
return false;
}
async run(fn) {
@@ -148,22 +149,19 @@ exports.Task = class Task {
assert.equal(this.#running, false)
this.#running = true
const startData = this.#startData
if (startData !== undefined) {
this.#startData = undefined
this.#emit('start', startData)
}
this.#maybeStart()
try {
const result = await asyncStorage.run(this, fn)
this.#handleMaybeAbortion(result)
this.#running = false
return result
} catch (result) {
if (!this.#handleMaybeAbortion(result)) {
this.#status = FAILURE
this.#emit('end', { status: FAILURE, result })
}
const { signal } = this.#abortController
const aborted = signal.aborted && result === signal.reason
const status = aborted ? ABORTED : FAILURE
this.#status = status
this.#emit('end', { status, result })
throw result
}
}

341
@vates/task/index.test.js Normal file
View File

@@ -0,0 +1,341 @@
'use strict'
const assert = require('node:assert').strict
const { describe, it } = require('test')
const { Task } = require('./index.js')
const noop = Function.prototype
function assertEvent(task, expected, eventIndex = -1) {
const logs = task.$events
const actual = logs[eventIndex < 0 ? logs.length + eventIndex : eventIndex]
assert.equal(typeof actual, 'object')
assert.equal(typeof actual.id, 'string')
assert.equal(typeof actual.timestamp, 'number')
for (const keys of Object.keys(expected)) {
assert.equal(actual[keys], expected[keys])
}
}
// like new Task() but with a custom onProgress which adds event to task.$events
function createTask(opts) {
const events = []
const task = new Task({ ...opts, onProgress: events.push.bind(events) })
task.$events = events
return task
}
describe('Task', function () {
describe('contructor', function () {
it('data properties are passed to the start event', async function () {
const data = { foo: 0, bar: 1 }
const task = createTask({ data })
await task.run(noop)
assertEvent(task, { ...data, type: 'start' }, 0)
})
})
it('subtasks events are passed to root task', async function () {
const task = createTask()
const result = {}
await task.run(async () => {
await new Task().run(() => result)
})
assert.equal(task.$events.length, 4)
assertEvent(task, { type: 'start', parentId: task.id }, 1)
assertEvent(task, { type: 'end', status: 'success', result }, 2)
})
describe('.abortSignal', function () {
it('is undefined when run outside a task', function () {
assert.equal(Task.abortSignal, undefined)
})
it('is the current abort signal when run inside a task', async function () {
const task = createTask()
await task.run(() => {
const { abortSignal } = Task
assert.equal(abortSignal.aborted, false)
task.abort()
assert.equal(abortSignal.aborted, true)
})
})
})
describe('.abort()', function () {
it('aborts if the task throws fails with the abort reason', async function () {
const task = createTask()
const reason = {}
await task
.run(() => {
task.abort(reason)
Task.abortSignal.throwIfAborted()
})
.catch(noop)
assert.equal(task.status, 'aborted')
assert.equal(task.$events.length, 2)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'aborted', result: reason }, 1)
})
it('does not abort if the task fails without the abort reason', async function () {
const task = createTask()
const result = new Error()
await task
.run(() => {
task.abort({})
throw result
})
.catch(noop)
assert.equal(task.status, 'failure')
assert.equal(task.$events.length, 2)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'failure', result }, 1)
})
it('does not abort if the task succeed', async function () {
const task = createTask()
const result = {}
await task
.run(() => {
task.abort({})
return result
})
.catch(noop)
assert.equal(task.status, 'success')
assert.equal(task.$events.length, 2)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'success', result }, 1)
})
it('aborts before task is running', function () {
const task = createTask()
const reason = {}
task.abort(reason)
assert.equal(task.status, 'aborted')
assert.equal(task.$events.length, 2)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'aborted', result: reason }, 1)
})
})
describe('.info()', function () {
it('does nothing when run outside a task', function () {
Task.info('foo')
})
it('emits an info message when run inside a task', async function () {
const task = createTask()
await task.run(() => {
Task.info('foo')
assertEvent(task, {
data: undefined,
message: 'foo',
type: 'info',
})
})
})
})
describe('.set()', function () {
it('does nothing when run outside a task', function () {
Task.set('progress', 10)
})
it('emits an info message when run inside a task', async function () {
const task = createTask()
await task.run(() => {
Task.set('progress', 10)
assertEvent(task, {
name: 'progress',
type: 'property',
value: 10,
})
})
})
})
describe('.warning()', function () {
it('does nothing when run outside a task', function () {
Task.warning('foo')
})
it('emits an warning message when run inside a task', async function () {
const task = createTask()
await task.run(() => {
Task.warning('foo')
assertEvent(task, {
data: undefined,
message: 'foo',
type: 'warning',
})
})
})
})
describe('#id', function () {
it('can be set', function () {
const task = createTask()
task.id = 'foo'
assert.equal(task.id, 'foo')
})
it('cannot be set more than once', function () {
const task = createTask()
task.id = 'foo'
assert.throws(() => {
task.id = 'bar'
}, TypeError)
})
it('is randomly generated if not set', function () {
assert.notEqual(createTask().id, createTask().id)
})
it('cannot be set after being observed', function () {
const task = createTask()
noop(task.id)
assert.throws(() => {
task.id = 'bar'
}, TypeError)
})
})
describe('#status', function () {
it('starts as pending', function () {
assert.equal(createTask().status, 'pending')
})
it('changes to success when finish without error', async function () {
const task = createTask()
await task.run(noop)
assert.equal(task.status, 'success')
})
it('changes to failure when finish with error', async function () {
const task = createTask()
await task
.run(() => {
throw Error()
})
.catch(noop)
assert.equal(task.status, 'failure')
})
it('changes to aborted after run is complete', async function () {
const task = createTask()
await task
.run(() => {
task.abort()
assert.equal(task.status, 'pending')
Task.abortSignal.throwIfAborted()
})
.catch(noop)
assert.equal(task.status, 'aborted')
})
it('changes to aborted if aborted when not running', async function () {
const task = createTask()
task.abort()
assert.equal(task.status, 'aborted')
})
})
function makeRunTests(run) {
it('starts the task', async function () {
const task = createTask()
await run(task, () => {
assertEvent(task, { type: 'start' })
})
})
it('finishes the task on success', async function () {
const task = createTask()
await run(task, () => 'foo')
assert.equal(task.status, 'success')
assertEvent(task, {
status: 'success',
result: 'foo',
type: 'end',
})
})
it('fails the task on error', async function () {
const task = createTask()
const e = new Error()
await run(task, () => {
throw e
}).catch(noop)
assert.equal(task.status, 'failure')
assertEvent(task, {
status: 'failure',
result: e,
type: 'end',
})
})
}
describe('.run', function () {
makeRunTests((task, fn) => task.run(fn))
})
describe('.wrap', function () {
makeRunTests((task, fn) => task.wrap(fn)())
})
function makeRunInsideTests(run) {
it('starts the task', async function () {
const task = createTask()
await run(task, () => {
assertEvent(task, { type: 'start' })
})
})
it('does not finish the task on success', async function () {
const task = createTask()
await run(task, () => 'foo')
assert.equal(task.status, 'pending')
})
it('fails the task on error', async function () {
const task = createTask()
const e = new Error()
await run(task, () => {
throw e
}).catch(noop)
assert.equal(task.status, 'failure')
assertEvent(task, {
status: 'failure',
result: e,
type: 'end',
})
})
}
describe('.runInside', function () {
makeRunInsideTests((task, fn) => task.runInside(fn))
})
describe('.wrapInside', function () {
makeRunInsideTests((task, fn) => task.wrapInside(fn)())
})
})

View File

@@ -13,11 +13,19 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.0.1",
"version": "0.1.2",
"engines": {
"node": ">=14"
},
"devDependencies": {
"test": "^3.3.0"
},
"scripts": {
"postversion": "npm publish --access public"
"postversion": "npm publish --access public",
"test": "node--test"
},
"exports": {
".": "./index.js",
"./combineEvents": "./combineEvents.js"
}
}

View File

@@ -7,8 +7,8 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.32.0",
"@xen-orchestra/fs": "^3.3.2",
"@xen-orchestra/backups": "^0.36.1",
"@xen-orchestra/fs": "^3.3.4",
"filenamify": "^4.1.0",
"getopts": "^2.2.5",
"lodash": "^4.17.15",
@@ -27,7 +27,7 @@
"scripts": {
"postversion": "npm publish --access public"
},
"version": "1.0.2",
"version": "1.0.6",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Vates SAS",

View File

@@ -1,295 +0,0 @@
'use strict'
const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map')
const Disposable = require('promise-toolbox/Disposable')
const ignoreErrors = require('promise-toolbox/ignoreErrors')
const pTimeout = require('promise-toolbox/timeout')
const { compileTemplate } = require('@xen-orchestra/template')
const { limitConcurrency } = require('limit-concurrency-decorator')
const { extractIdsFromSimplePattern } = require('./extractIdsFromSimplePattern.js')
const { PoolMetadataBackup } = require('./_PoolMetadataBackup.js')
const { Task } = require('./Task.js')
const { VmBackup } = require('./_VmBackup.js')
const { XoMetadataBackup } = require('./_XoMetadataBackup.js')
const createStreamThrottle = require('./_createStreamThrottle.js')
const noop = Function.prototype
const getAdaptersByRemote = adapters => {
const adaptersByRemote = {}
adapters.forEach(({ adapter, remoteId }) => {
adaptersByRemote[remoteId] = adapter
})
return adaptersByRemote
}
const runTask = (...args) => Task.run(...args).catch(noop) // errors are handled by logs
const DEFAULT_SETTINGS = {
getRemoteTimeout: 300e3,
reportWhen: 'failure',
}
const DEFAULT_VM_SETTINGS = {
bypassVdiChainsCheck: false,
checkpointSnapshot: false,
concurrency: 2,
copyRetention: 0,
deleteFirst: false,
exportRetention: 0,
fullInterval: 0,
healthCheckSr: undefined,
healthCheckVmsWithTags: [],
maxExportRate: 0,
maxMergedDeltasPerRun: Infinity,
offlineBackup: false,
offlineSnapshot: false,
snapshotRetention: 0,
timeout: 0,
useNbd: false,
unconditionalSnapshot: false,
vmTimeout: 0,
}
const DEFAULT_METADATA_SETTINGS = {
retentionPoolMetadata: 0,
retentionXoMetadata: 0,
}
class RemoteTimeoutError extends Error {
constructor(remoteId) {
super('timeout while getting the remote ' + remoteId)
this.remoteId = remoteId
}
}
exports.Backup = class Backup {
constructor({ config, getAdapter, getConnectedRecord, job, schedule }) {
this._config = config
this._getRecord = getConnectedRecord
this._job = job
this._schedule = schedule
this._getSnapshotNameLabel = compileTemplate(config.snapshotNameLabelTpl, {
'{job.name}': job.name,
'{vm.name_label}': vm => vm.name_label,
})
const { type } = job
const baseSettings = { ...DEFAULT_SETTINGS }
if (type === 'backup') {
Object.assign(baseSettings, DEFAULT_VM_SETTINGS, config.defaultSettings, config.vm?.defaultSettings)
this.run = this._runVmBackup
} else if (type === 'metadataBackup') {
Object.assign(baseSettings, DEFAULT_METADATA_SETTINGS, config.defaultSettings, config.metadata?.defaultSettings)
this.run = this._runMetadataBackup
} else {
throw new Error(`No runner for the backup type ${type}`)
}
Object.assign(baseSettings, job.settings[''])
this._baseSettings = baseSettings
this._settings = { ...baseSettings, ...job.settings[schedule.id] }
const { getRemoteTimeout } = this._settings
this._getAdapter = async function (remoteId) {
try {
const disposable = await pTimeout.call(getAdapter(remoteId), getRemoteTimeout, new RemoteTimeoutError(remoteId))
return new Disposable(() => disposable.dispose(), {
adapter: disposable.value,
remoteId,
})
} catch (error) {
// See https://github.com/vatesfr/xen-orchestra/commit/6aa6cfba8ec939c0288f0fa740f6dfad98c43cbb
runTask(
{
name: 'get remote adapter',
data: { type: 'remote', id: remoteId },
},
() => Promise.reject(error)
)
}
}
}
async _runMetadataBackup() {
const schedule = this._schedule
const job = this._job
const remoteIds = extractIdsFromSimplePattern(job.remotes)
if (remoteIds.length === 0) {
throw new Error('metadata backup job cannot run without remotes')
}
const config = this._config
const poolIds = extractIdsFromSimplePattern(job.pools)
const isEmptyPools = poolIds.length === 0
const isXoMetadata = job.xoMetadata !== undefined
if (!isXoMetadata && isEmptyPools) {
throw new Error('no metadata mode found')
}
const settings = this._settings
const { retentionPoolMetadata, retentionXoMetadata } = settings
if (
(retentionPoolMetadata === 0 && retentionXoMetadata === 0) ||
(!isXoMetadata && retentionPoolMetadata === 0) ||
(isEmptyPools && retentionXoMetadata === 0)
) {
throw new Error('no retentions corresponding to the metadata modes found')
}
await Disposable.use(
Disposable.all(
poolIds.map(id =>
this._getRecord('pool', id).catch(error => {
// See https://github.com/vatesfr/xen-orchestra/commit/6aa6cfba8ec939c0288f0fa740f6dfad98c43cbb
runTask(
{
name: 'get pool record',
data: { type: 'pool', id },
},
() => Promise.reject(error)
)
})
)
),
Disposable.all(remoteIds.map(id => this._getAdapter(id))),
async (pools, remoteAdapters) => {
// remove adapters that failed (already handled)
remoteAdapters = remoteAdapters.filter(_ => _ !== undefined)
if (remoteAdapters.length === 0) {
return
}
remoteAdapters = getAdaptersByRemote(remoteAdapters)
// remove pools that failed (already handled)
pools = pools.filter(_ => _ !== undefined)
const promises = []
if (pools.length !== 0 && settings.retentionPoolMetadata !== 0) {
promises.push(
asyncMap(pools, async pool =>
runTask(
{
name: `Starting metadata backup for the pool (${pool.$id}). (${job.id})`,
data: {
id: pool.$id,
pool,
poolMaster: await ignoreErrors.call(pool.$xapi.getRecord('host', pool.master)),
type: 'pool',
},
},
() =>
new PoolMetadataBackup({
config,
job,
pool,
remoteAdapters,
schedule,
settings,
}).run()
)
)
)
}
if (job.xoMetadata !== undefined && settings.retentionXoMetadata !== 0) {
promises.push(
runTask(
{
name: `Starting XO metadata backup. (${job.id})`,
data: {
type: 'xo',
},
},
() =>
new XoMetadataBackup({
config,
job,
remoteAdapters,
schedule,
settings,
}).run()
)
)
}
await Promise.all(promises)
}
)
}
async _runVmBackup() {
const job = this._job
// FIXME: proper SimpleIdPattern handling
const getSnapshotNameLabel = this._getSnapshotNameLabel
const schedule = this._schedule
const settings = this._settings
const throttleStream = createStreamThrottle(settings.maxExportRate)
const config = this._config
await Disposable.use(
Disposable.all(
extractIdsFromSimplePattern(job.srs).map(id =>
this._getRecord('SR', id).catch(error => {
runTask(
{
name: 'get SR record',
data: { type: 'SR', id },
},
() => Promise.reject(error)
)
})
)
),
Disposable.all(extractIdsFromSimplePattern(job.remotes).map(id => this._getAdapter(id))),
() => (settings.healthCheckSr !== undefined ? this._getRecord('SR', settings.healthCheckSr) : undefined),
async (srs, remoteAdapters, healthCheckSr) => {
// remove adapters that failed (already handled)
remoteAdapters = remoteAdapters.filter(_ => _ !== undefined)
// remove srs that failed (already handled)
srs = srs.filter(_ => _ !== undefined)
if (remoteAdapters.length === 0 && srs.length === 0 && settings.snapshotRetention === 0) {
return
}
const vmIds = extractIdsFromSimplePattern(job.vms)
Task.info('vms', { vms: vmIds })
remoteAdapters = getAdaptersByRemote(remoteAdapters)
const allSettings = this._job.settings
const baseSettings = this._baseSettings
const handleVm = vmUuid =>
runTask({ name: 'backup VM', data: { type: 'VM', id: vmUuid } }, () =>
Disposable.use(this._getRecord('VM', vmUuid), vm =>
new VmBackup({
baseSettings,
config,
getSnapshotNameLabel,
healthCheckSr,
job,
remoteAdapters,
schedule,
settings: { ...settings, ...allSettings[vm.uuid] },
srs,
throttleStream,
vm,
}).run()
)
)
const { concurrency } = settings
await asyncMapSettled(vmIds, concurrency === 0 ? handleVm : limitConcurrency(concurrency)(handleVm))
}
)
}
}

View File

@@ -3,12 +3,14 @@
const { Task } = require('./Task')
exports.HealthCheckVmBackup = class HealthCheckVmBackup {
#xapi
#restoredVm
#timeout
#xapi
constructor({ restoredVm, xapi }) {
constructor({ restoredVm, timeout = 10 * 60 * 1000, xapi }) {
this.#restoredVm = restoredVm
this.#xapi = xapi
this.#timeout = timeout
}
async run() {
@@ -23,7 +25,12 @@ exports.HealthCheckVmBackup = class HealthCheckVmBackup {
// remove vifs
await Promise.all(restoredVm.$VIFs.map(vif => xapi.callAsync('VIF.destroy', vif.$ref)))
const waitForScript = restoredVm.tags.includes('xo-backup-health-check-xenstore')
if (waitForScript) {
await restoredVm.set_xenstore_data({
'vm-data/xo-backup-health-check': 'planned',
})
}
const start = new Date()
// start Vm
@@ -34,7 +41,7 @@ exports.HealthCheckVmBackup = class HealthCheckVmBackup {
false // Skip pre-boot checks?
)
const started = new Date()
const timeout = 10 * 60 * 1000
const timeout = this.#timeout
const startDuration = started - start
let remainingTimeout = timeout - startDuration
@@ -52,12 +59,52 @@ exports.HealthCheckVmBackup = class HealthCheckVmBackup {
remainingTimeout -= running - started
if (remainingTimeout < 0) {
throw new Error(`local xapi did not get Runnig state for VM ${restoredId} after ${timeout / 1000} second`)
throw new Error(`local xapi did not get Running state for VM ${restoredId} after ${timeout / 1000} second`)
}
// wait for the guest tool version to be defined
await xapi.waitObjectState(restoredVm.guest_metrics, gm => gm?.PV_drivers_version?.major !== undefined, {
timeout: remainingTimeout,
})
const guestToolsReady = new Date()
remainingTimeout -= guestToolsReady - running
if (remainingTimeout < 0) {
throw new Error(`local xapi did not get he guest tools check ${restoredId} after ${timeout / 1000} second`)
}
if (waitForScript) {
const startedRestoredVm = await xapi.waitObjectState(
restoredVm.$ref,
vm =>
vm?.xenstore_data !== undefined &&
(vm.xenstore_data['vm-data/xo-backup-health-check'] === 'success' ||
vm.xenstore_data['vm-data/xo-backup-health-check'] === 'failure'),
{
timeout: remainingTimeout,
}
)
const scriptOk = new Date()
remainingTimeout -= scriptOk - guestToolsReady
if (remainingTimeout < 0) {
throw new Error(
`Backup health check script did not update vm-data/xo-backup-health-check of ${restoredId} after ${
timeout / 1000
} second, got ${
startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check']
} instead of 'success' or 'failure'`
)
}
if (startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check'] !== 'success') {
const message = startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check-error']
if (message) {
throw new Error(`Backup health check script failed with message ${message} for VM ${restoredId} `)
} else {
throw new Error(`Backup health check script failed for VM ${restoredId} `)
}
}
Task.info('Backup health check script successfully executed')
}
}
)
}

View File

@@ -3,7 +3,7 @@
const assert = require('assert')
const { formatFilenameDate } = require('./_filenameDate.js')
const { importDeltaVm } = require('./_deltaVm.js')
const { importIncrementalVm } = require('./_incrementalVm.js')
const { Task } = require('./Task.js')
const { watchStreamSize } = require('./_watchStreamSize.js')
@@ -49,7 +49,7 @@ exports.ImportVmBackup = class ImportVmBackup {
const vmRef = isFull
? await xapi.VM_import(backup, srRef)
: await importDeltaVm(backup, await xapi.getRecord('SR', srRef), {
: await importIncrementalVm(backup, await xapi.getRecord('SR', srRef), {
...this._importDeltaVmSettings,
detectBase: false,
})

View File

@@ -10,14 +10,7 @@ const groupBy = require('lodash/groupBy.js')
const pickBy = require('lodash/pickBy.js')
const { dirname, join, normalize, resolve } = require('path')
const { createLogger } = require('@xen-orchestra/log')
const {
createVhdDirectoryFromStream,
createVhdStreamWithLength,
openVhd,
VhdAbstract,
VhdDirectory,
VhdSynthetic,
} = require('vhd-lib')
const { createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdDirectory, VhdSynthetic } = require('vhd-lib')
const { deduped } = require('@vates/disposable/deduped.js')
const { decorateMethodsWith } = require('@vates/decorate-with')
const { compose } = require('@vates/compose')
@@ -35,11 +28,10 @@ const { isMetadataFile } = require('./_backupType.js')
const { isValidXva } = require('./_isValidXva.js')
const { listPartitions, LVM_PARTITION_TYPE } = require('./_listPartitions.js')
const { lvs, pvs } = require('./_lvm.js')
const { watchStreamSize } = require('./_watchStreamSize')
const { watchStreamSize } = require('./_watchStreamSize.js')
// @todo : this import is marked extraneous , sould be fixed when lib is published
const { mount } = require('@vates/fuse-vhd')
const { asyncEach } = require('@vates/async-each')
const { strictEqual } = require('assert')
const DIR_XO_CONFIG_BACKUPS = 'xo-config-backups'
exports.DIR_XO_CONFIG_BACKUPS = DIR_XO_CONFIG_BACKUPS
@@ -666,7 +658,7 @@ class RemoteAdapter {
return path
}
async writeVhd(path, input, { checksum = true, validator = noop, writeBlockConcurrency, nbdClient } = {}) {
async writeVhd(path, input, { checksum = true, validator = noop, writeBlockConcurrency } = {}) {
const handler = this._handler
if (this.useVhdDirectory()) {
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
@@ -677,42 +669,21 @@ class RemoteAdapter {
await input.task
return validator.apply(this, arguments)
},
nbdClient,
})
await VhdAbstract.createAlias(handler, path, dataPath)
return size
} else {
const inputWithSize = await createVhdStreamWithLength(input)
return this.outputStream(path, inputWithSize, { checksum, validator, expectedSize: inputWithSize.length })
return this.outputStream(path, input, { checksum, validator })
}
}
async outputStream(path, input, { checksum = true, validator = noop, expectedSize } = {}) {
async outputStream(path, input, { checksum = true, validator = noop } = {}) {
const container = watchStreamSize(input)
await this._handler.outputStream(path, input, {
checksum,
dirMode: this._dirMode,
async validator() {
await input.task
if (expectedSize !== undefined) {
// check that we read all the stream
strictEqual(
container.size,
expectedSize,
`transferred size ${container.size}, expected file size : ${expectedSize}`
)
}
let size
try {
size = await this._handler.getSize(path)
} catch (err) {
// can fail is the remote is encrypted
}
if (size !== undefined) {
// check that everything is written to disk
strictEqual(size, container.size, `written size ${size}, transfered size : ${container.size}`)
}
return validator.apply(this, arguments)
},
})
@@ -748,7 +719,7 @@ class RemoteAdapter {
async readDeltaVmBackup(metadata, ignoredVdis) {
const handler = this._handler
const { vbds, vhds, vifs, vm } = metadata
const { vbds, vhds, vifs, vm, vmSnapshot } = metadata
const dir = dirname(metadata._filename)
const vdis = ignoredVdis === undefined ? metadata.vdis : pickBy(metadata.vdis, vdi => !ignoredVdis.has(vdi.uuid))
@@ -763,7 +734,7 @@ class RemoteAdapter {
vdis,
version: '1.0.0',
vifs,
vm,
vm: { ...vm, suspend_VDI: vmSnapshot.suspend_VDI },
}
}
@@ -775,7 +746,49 @@ class RemoteAdapter {
// _filename is a private field used to compute the backup id
//
// it's enumerable to make it cacheable
return { ...JSON.parse(await this._handler.readFile(path)), _filename: path }
const metadata = { ...JSON.parse(await this._handler.readFile(path)), _filename: path }
// backups created on XenServer < 7.1 via JSON in XML-RPC transports have boolean values encoded as integers, which make them unusable with more recent XAPIs
if (typeof metadata.vm.is_a_template === 'number') {
const properties = {
vbds: ['bootable', 'unpluggable', 'storage_lock', 'empty', 'currently_attached'],
vdis: [
'sharable',
'read_only',
'storage_lock',
'managed',
'missing',
'is_a_snapshot',
'allow_caching',
'metadata_latest',
],
vifs: ['currently_attached', 'MAC_autogenerated'],
vm: ['is_a_template', 'is_control_domain', 'ha_always_run', 'is_a_snapshot', 'is_snapshot_from_vmpp'],
vmSnapshot: ['is_a_template', 'is_control_domain', 'ha_always_run', 'is_snapshot_from_vmpp'],
}
function fixBooleans(obj, properties) {
properties.forEach(property => {
if (typeof obj[property] === 'number') {
obj[property] = obj[property] === 1
}
})
}
for (const [key, propertiesInKey] of Object.entries(properties)) {
const value = metadata[key]
if (value !== undefined) {
// some properties of the metadata are collections indexed by the opaqueRef
const isCollection = Object.keys(value).some(subKey => subKey.startsWith('OpaqueRef:'))
if (isCollection) {
Object.values(value).forEach(subValue => fixBooleans(subValue, propertiesInKey))
} else {
fixBooleans(value, propertiesInKey)
}
}
}
}
return metadata
}
}

View File

@@ -1,7 +1,7 @@
'use strict'
const { DIR_XO_POOL_METADATA_BACKUPS } = require('./RemoteAdapter.js')
const { PATH_DB_DUMP } = require('./_PoolMetadataBackup.js')
const { PATH_DB_DUMP } = require('./_backupJob/PoolMetadataBackup.js')
exports.RestoreMetadataBackup = class RestoreMetadataBackup {
constructor({ backupId, handler, xapi }) {

View File

@@ -1,501 +0,0 @@
'use strict'
const assert = require('assert')
const findLast = require('lodash/findLast.js')
const groupBy = require('lodash/groupBy.js')
const ignoreErrors = require('promise-toolbox/ignoreErrors')
const keyBy = require('lodash/keyBy.js')
const mapValues = require('lodash/mapValues.js')
const { asyncMap } = require('@xen-orchestra/async-map')
const { createLogger } = require('@xen-orchestra/log')
const { decorateMethodsWith } = require('@vates/decorate-with')
const { defer } = require('golike-defer')
const { formatDateTime } = require('@xen-orchestra/xapi')
const { DeltaBackupWriter } = require('./writers/DeltaBackupWriter.js')
const { DeltaReplicationWriter } = require('./writers/DeltaReplicationWriter.js')
const { exportDeltaVm } = require('./_deltaVm.js')
const { forkStreamUnpipe } = require('./_forkStreamUnpipe.js')
const { FullBackupWriter } = require('./writers/FullBackupWriter.js')
const { FullReplicationWriter } = require('./writers/FullReplicationWriter.js')
const { getOldEntries } = require('./_getOldEntries.js')
const { Task } = require('./Task.js')
const { watchStreamSize } = require('./_watchStreamSize.js')
const { debug, warn } = createLogger('xo:backups:VmBackup')
class AggregateError extends Error {
constructor(errors, message) {
super(message)
this.errors = errors
}
}
const asyncEach = async (iterable, fn, thisArg = iterable) => {
for (const item of iterable) {
await fn.call(thisArg, item)
}
}
const forkDeltaExport = deltaExport =>
Object.create(deltaExport, {
streams: {
value: mapValues(deltaExport.streams, forkStreamUnpipe),
},
})
class VmBackup {
constructor({
config,
getSnapshotNameLabel,
healthCheckSr,
job,
remoteAdapters,
remotes,
schedule,
settings,
srs,
throttleStream,
vm,
}) {
if (vm.other_config['xo:backup:job'] === job.id && 'start' in vm.blocked_operations) {
// don't match replicated VMs created by this very job otherwise they
// will be replicated again and again
throw new Error('cannot backup a VM created by this very job')
}
this.config = config
this.job = job
this.remoteAdapters = remoteAdapters
this.scheduleId = schedule.id
this.timestamp = undefined
// VM currently backed up
this.vm = vm
const { tags } = this.vm
// VM (snapshot) that is really exported
this.exportedVm = undefined
this._fullVdisRequired = undefined
this._getSnapshotNameLabel = getSnapshotNameLabel
this._isDelta = job.mode === 'delta'
this._healthCheckSr = healthCheckSr
this._jobId = job.id
this._jobSnapshots = undefined
this._throttleStream = throttleStream
this._xapi = vm.$xapi
// Base VM for the export
this._baseVm = undefined
// Settings for this specific run (job, schedule, VM)
if (tags.includes('xo-memory-backup')) {
settings.checkpointSnapshot = true
}
if (tags.includes('xo-offline-backup')) {
settings.offlineSnapshot = true
}
this._settings = settings
// Create writers
{
const writers = new Set()
this._writers = writers
const [BackupWriter, ReplicationWriter] = this._isDelta
? [DeltaBackupWriter, DeltaReplicationWriter]
: [FullBackupWriter, FullReplicationWriter]
const allSettings = job.settings
Object.keys(remoteAdapters).forEach(remoteId => {
const targetSettings = {
...settings,
...allSettings[remoteId],
}
if (targetSettings.exportRetention !== 0) {
writers.add(new BackupWriter({ backup: this, remoteId, settings: targetSettings }))
}
})
srs.forEach(sr => {
const targetSettings = {
...settings,
...allSettings[sr.uuid],
}
if (targetSettings.copyRetention !== 0) {
writers.add(new ReplicationWriter({ backup: this, sr, settings: targetSettings }))
}
})
}
}
// calls fn for each function, warns of any errors, and throws only if there are no writers left
async _callWriters(fn, step, parallel = true) {
const writers = this._writers
const n = writers.size
if (n === 0) {
return
}
async function callWriter(writer) {
const { name } = writer.constructor
try {
debug('writer step starting', { step, writer: name })
await fn(writer)
debug('writer step succeeded', { duration: step, writer: name })
} catch (error) {
writers.delete(writer)
warn('writer step failed', { error, step, writer: name })
// these two steps are the only one that are not already in their own sub tasks
if (step === 'writer.checkBaseVdis()' || step === 'writer.beforeBackup()') {
Task.warning(
`the writer ${name} has failed the step ${step} with error ${error.message}. It won't be used anymore in this job execution.`
)
}
throw error
}
}
if (n === 1) {
const [writer] = writers
return callWriter(writer)
}
const errors = []
await (parallel ? asyncMap : asyncEach)(writers, async function (writer) {
try {
await callWriter(writer)
} catch (error) {
errors.push(error)
}
})
if (writers.size === 0) {
throw new AggregateError(errors, 'all targets have failed, step: ' + step)
}
}
// ensure the VM itself does not have any backup metadata which would be
// copied on manual snapshots and interfere with the backup jobs
async _cleanMetadata() {
const { vm } = this
if ('xo:backup:job' in vm.other_config) {
await vm.update_other_config({
'xo:backup:datetime': null,
'xo:backup:deltaChainLength': null,
'xo:backup:exported': null,
'xo:backup:job': null,
'xo:backup:schedule': null,
'xo:backup:vm': null,
})
}
}
async _snapshot() {
const { vm } = this
const xapi = this._xapi
const settings = this._settings
const doSnapshot =
settings.unconditionalSnapshot ||
this._isDelta ||
(!settings.offlineBackup && vm.power_state === 'Running') ||
settings.snapshotRetention !== 0
if (doSnapshot) {
await Task.run({ name: 'snapshot' }, async () => {
if (!settings.bypassVdiChainsCheck) {
await vm.$assertHealthyVdiChains()
}
const snapshotRef = await vm[settings.checkpointSnapshot ? '$checkpoint' : '$snapshot']({
ignoreNobakVdis: true,
name_label: this._getSnapshotNameLabel(vm),
unplugVusbs: true,
})
this.timestamp = Date.now()
await xapi.setFieldEntries('VM', snapshotRef, 'other_config', {
'xo:backup:datetime': formatDateTime(this.timestamp),
'xo:backup:job': this._jobId,
'xo:backup:schedule': this.scheduleId,
'xo:backup:vm': vm.uuid,
})
this.exportedVm = await xapi.getRecord('VM', snapshotRef)
return this.exportedVm.uuid
})
} else {
this.exportedVm = vm
this.timestamp = Date.now()
}
}
async _copyDelta() {
const { exportedVm } = this
const baseVm = this._baseVm
const fullVdisRequired = this._fullVdisRequired
const isFull = fullVdisRequired === undefined || fullVdisRequired.size !== 0
await this._callWriters(writer => writer.prepare({ isFull }), 'writer.prepare()')
const deltaExport = await exportDeltaVm(exportedVm, baseVm, {
fullVdisRequired,
})
const sizeContainers = mapValues(deltaExport.streams, stream => watchStreamSize(stream))
deltaExport.streams = mapValues(deltaExport.streams, this._throttleStream)
const timestamp = Date.now()
await this._callWriters(
writer =>
writer.transfer({
deltaExport: forkDeltaExport(deltaExport),
sizeContainers,
timestamp,
}),
'writer.transfer()'
)
this._baseVm = exportedVm
if (baseVm !== undefined) {
await exportedVm.update_other_config(
'xo:backup:deltaChainLength',
String(+(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1)
)
}
// not the case if offlineBackup
if (exportedVm.is_a_snapshot) {
await exportedVm.update_other_config('xo:backup:exported', 'true')
}
const size = Object.values(sizeContainers).reduce((sum, { size }) => sum + size, 0)
const end = Date.now()
const duration = end - timestamp
debug('transfer complete', {
duration,
speed: duration !== 0 ? (size * 1e3) / 1024 / 1024 / duration : 0,
size,
})
await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
}
async _copyFull() {
const { compression } = this.job
const stream = this._throttleStream(
await this._xapi.VM_export(this.exportedVm.$ref, {
compress: Boolean(compression) && (compression === 'native' ? 'gzip' : 'zstd'),
useSnapshot: false,
})
)
const sizeContainer = watchStreamSize(stream)
const timestamp = Date.now()
await this._callWriters(
writer =>
writer.run({
sizeContainer,
stream: forkStreamUnpipe(stream),
timestamp,
}),
'writer.run()'
)
const { size } = sizeContainer
const end = Date.now()
const duration = end - timestamp
debug('transfer complete', {
duration,
speed: duration !== 0 ? (size * 1e3) / 1024 / 1024 / duration : 0,
size,
})
}
async _fetchJobSnapshots() {
const jobId = this._jobId
const vmRef = this.vm.$ref
const xapi = this._xapi
const snapshotsRef = await xapi.getField('VM', vmRef, 'snapshots')
const snapshotsOtherConfig = await asyncMap(snapshotsRef, ref => xapi.getField('VM', ref, 'other_config'))
const snapshots = []
snapshotsOtherConfig.forEach((other_config, i) => {
if (other_config['xo:backup:job'] === jobId) {
snapshots.push({ other_config, $ref: snapshotsRef[i] })
}
})
snapshots.sort((a, b) => (a.other_config['xo:backup:datetime'] < b.other_config['xo:backup:datetime'] ? -1 : 1))
this._jobSnapshots = snapshots
}
async _removeUnusedSnapshots() {
const allSettings = this.job.settings
const baseSettings = this._baseSettings
const baseVmRef = this._baseVm?.$ref
const snapshotsPerSchedule = groupBy(this._jobSnapshots, _ => _.other_config['xo:backup:schedule'])
const xapi = this._xapi
await asyncMap(Object.entries(snapshotsPerSchedule), ([scheduleId, snapshots]) => {
const settings = {
...baseSettings,
...allSettings[scheduleId],
...allSettings[this.vm.uuid],
}
return asyncMap(getOldEntries(settings.snapshotRetention, snapshots), ({ $ref }) => {
if ($ref !== baseVmRef) {
return xapi.VM_destroy($ref)
}
})
})
}
async _selectBaseVm() {
const xapi = this._xapi
let baseVm = findLast(this._jobSnapshots, _ => 'xo:backup:exported' in _.other_config)
if (baseVm === undefined) {
debug('no base VM found')
return
}
const fullInterval = this._settings.fullInterval
const deltaChainLength = +(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1
if (!(fullInterval === 0 || fullInterval > deltaChainLength)) {
debug('not using base VM becaust fullInterval reached')
return
}
const srcVdis = keyBy(await xapi.getRecords('VDI', await this.vm.$getDisks()), '$ref')
// resolve full record
baseVm = await xapi.getRecord('VM', baseVm.$ref)
const baseUuidToSrcVdi = new Map()
await asyncMap(await baseVm.$getDisks(), async baseRef => {
const [baseUuid, snapshotOf] = await Promise.all([
xapi.getField('VDI', baseRef, 'uuid'),
xapi.getField('VDI', baseRef, 'snapshot_of'),
])
const srcVdi = srcVdis[snapshotOf]
if (srcVdi !== undefined) {
baseUuidToSrcVdi.set(baseUuid, srcVdi)
} else {
debug('ignore snapshot VDI because no longer present on VM', {
vdi: baseUuid,
})
}
})
const presentBaseVdis = new Map(baseUuidToSrcVdi)
await this._callWriters(
writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis, baseVm),
'writer.checkBaseVdis()',
false
)
if (presentBaseVdis.size === 0) {
debug('no base VM found')
return
}
const fullVdisRequired = new Set()
baseUuidToSrcVdi.forEach((srcVdi, baseUuid) => {
if (presentBaseVdis.has(baseUuid)) {
debug('found base VDI', {
base: baseUuid,
vdi: srcVdi.uuid,
})
} else {
debug('missing base VDI', {
base: baseUuid,
vdi: srcVdi.uuid,
})
fullVdisRequired.add(srcVdi.uuid)
}
})
this._baseVm = baseVm
this._fullVdisRequired = fullVdisRequired
}
async _healthCheck() {
const settings = this._settings
if (this._healthCheckSr === undefined) {
return
}
// check if current VM has tags
const { tags } = this.vm
const intersect = settings.healthCheckVmsWithTags.some(t => tags.includes(t))
if (settings.healthCheckVmsWithTags.length !== 0 && !intersect) {
return
}
await this._callWriters(writer => writer.healthCheck(this._healthCheckSr), 'writer.healthCheck()')
}
async run($defer) {
const settings = this._settings
assert(
!settings.offlineBackup || settings.snapshotRetention === 0,
'offlineBackup is not compatible with snapshotRetention'
)
await this._callWriters(async writer => {
await writer.beforeBackup()
$defer(async () => {
await writer.afterBackup()
})
}, 'writer.beforeBackup()')
await this._fetchJobSnapshots()
if (this._isDelta) {
await this._selectBaseVm()
}
await this._cleanMetadata()
await this._removeUnusedSnapshots()
const { vm } = this
const isRunning = vm.power_state === 'Running'
const startAfter = isRunning && (settings.offlineBackup ? 'backup' : settings.offlineSnapshot && 'snapshot')
if (startAfter) {
await vm.$callAsync('clean_shutdown')
}
try {
await this._snapshot()
if (startAfter === 'snapshot') {
ignoreErrors.call(vm.$callAsync('start', false, false))
}
if (this._writers.size !== 0) {
await (this._isDelta ? this._copyDelta() : this._copyFull())
}
} finally {
if (startAfter) {
ignoreErrors.call(vm.$callAsync('start', false, false))
}
await this._fetchJobSnapshots()
await this._removeUnusedSnapshots()
}
await this._healthCheck()
}
}
exports.VmBackup = VmBackup
decorateMethodsWith(VmBackup, {
run: defer,
})

View File

@@ -0,0 +1,51 @@
'use strict'
const Disposable = require('promise-toolbox/Disposable')
const pTimeout = require('promise-toolbox/timeout')
const { compileTemplate } = require('@xen-orchestra/template')
const { runTask } = require('./runTask.js')
const { RemoteTimeoutError } = require('./RemoteTimeoutError.js')
exports.DEFAULT_SETTINGS = {
getRemoteTimeout: 300e3,
reportWhen: 'failure',
}
exports.AbstractBackupJob = class AbstractBackupJob {
constructor({ config, getAdapter, getConnectedRecord, job, schedule }) {
this._config = config
this._getRecord = getConnectedRecord
this._job = job
this._schedule = schedule
this._getSnapshotNameLabel = compileTemplate(config.snapshotNameLabelTpl, {
'{job.name}': job.name,
'{vm.name_label}': vm => vm.name_label,
})
const baseSettings = this._computeBaseSettings(config, job)
this._baseSettings = baseSettings
this._settings = { ...baseSettings, ...job.settings[schedule.id] }
const { getRemoteTimeout } = this._settings
this._getAdapter = async function (remoteId) {
try {
const disposable = await pTimeout.call(getAdapter(remoteId), getRemoteTimeout, new RemoteTimeoutError(remoteId))
return new Disposable(() => disposable.dispose(), {
adapter: disposable.value,
remoteId,
})
} catch (error) {
// See https://github.com/vatesfr/xen-orchestra/commit/6aa6cfba8ec939c0288f0fa740f6dfad98c43cbb
runTask(
{
name: 'get remote adapter',
data: { type: 'remote', id: remoteId },
},
() => Promise.reject(error)
)
}
}
}
}

View File

@@ -0,0 +1,134 @@
'use strict'
const { asyncMap } = require('@xen-orchestra/async-map')
const Disposable = require('promise-toolbox/Disposable')
const ignoreErrors = require('promise-toolbox/ignoreErrors')
const { extractIdsFromSimplePattern } = require('../extractIdsFromSimplePattern.js')
const { PoolMetadataBackup } = require('./PoolMetadataBackup.js')
const { XoMetadataBackup } = require('./XoMetadataBackup.js')
const { DEFAULT_SETTINGS, AbstractBackupJob } = require('./AbstractBackupJob.js')
const { runTask } = require('./runTask.js')
const { getAdaptersByRemote } = require('./getAdaptersByRemote.js')
const DEFAULT_METADATA_SETTINGS = {
retentionPoolMetadata: 0,
retentionXoMetadata: 0,
}
exports.MetadatasBackupJob = class MetadatasBackupJob extends AbstractBackupJob {
_computeBaseSettings(config, job) {
const baseSettings = { ...DEFAULT_SETTINGS }
Object.assign(baseSettings, DEFAULT_METADATA_SETTINGS, config.defaultSettings, config.metadata?.defaultSettings)
Object.assign(baseSettings, job.settings[''])
return baseSettings
}
async run() {
const schedule = this._schedule
const job = this._job
const remoteIds = extractIdsFromSimplePattern(job.remotes)
if (remoteIds.length === 0) {
throw new Error('metadata backup job cannot run without remotes')
}
const config = this._config
const poolIds = extractIdsFromSimplePattern(job.pools)
const isEmptyPools = poolIds.length === 0
const isXoMetadata = job.xoMetadata !== undefined
if (!isXoMetadata && isEmptyPools) {
throw new Error('no metadata mode found')
}
const settings = this._settings
const { retentionPoolMetadata, retentionXoMetadata } = settings
if (
(retentionPoolMetadata === 0 && retentionXoMetadata === 0) ||
(!isXoMetadata && retentionPoolMetadata === 0) ||
(isEmptyPools && retentionXoMetadata === 0)
) {
throw new Error('no retentions corresponding to the metadata modes found')
}
await Disposable.use(
Disposable.all(
poolIds.map(id =>
this._getRecord('pool', id).catch(error => {
// See https://github.com/vatesfr/xen-orchestra/commit/6aa6cfba8ec939c0288f0fa740f6dfad98c43cbb
runTask(
{
name: 'get pool record',
data: { type: 'pool', id },
},
() => Promise.reject(error)
)
})
)
),
Disposable.all(remoteIds.map(id => this._getAdapter(id))),
async (pools, remoteAdapters) => {
// remove adapters that failed (already handled)
remoteAdapters = remoteAdapters.filter(_ => _ !== undefined)
if (remoteAdapters.length === 0) {
return
}
remoteAdapters = getAdaptersByRemote(remoteAdapters)
// remove pools that failed (already handled)
pools = pools.filter(_ => _ !== undefined)
const promises = []
if (pools.length !== 0 && settings.retentionPoolMetadata !== 0) {
promises.push(
asyncMap(pools, async pool =>
runTask(
{
name: `Starting metadata backup for the pool (${pool.$id}). (${job.id})`,
data: {
id: pool.$id,
pool,
poolMaster: await ignoreErrors.call(pool.$xapi.getRecord('host', pool.master)),
type: 'pool',
},
},
() =>
new PoolMetadataBackup({
config,
job,
pool,
remoteAdapters,
schedule,
settings,
}).run()
)
)
)
}
if (job.xoMetadata !== undefined && settings.retentionXoMetadata !== 0) {
promises.push(
runTask(
{
name: `Starting XO metadata backup. (${job.id})`,
data: {
type: 'xo',
},
},
() =>
new XoMetadataBackup({
config,
job,
remoteAdapters,
schedule,
settings,
}).run()
)
)
}
await Promise.all(promises)
}
)
}
}

View File

@@ -2,10 +2,10 @@
const { asyncMap } = require('@xen-orchestra/async-map')
const { DIR_XO_POOL_METADATA_BACKUPS } = require('./RemoteAdapter.js')
const { forkStreamUnpipe } = require('./_forkStreamUnpipe.js')
const { formatFilenameDate } = require('./_filenameDate.js')
const { Task } = require('./Task.js')
const { DIR_XO_POOL_METADATA_BACKUPS } = require('../RemoteAdapter.js')
const { forkStreamUnpipe } = require('./forkStreamUnpipe.js')
const { formatFilenameDate } = require('../_filenameDate.js')
const { Task } = require('../Task.js')
const PATH_DB_DUMP = '/pool/xmldbdump'
exports.PATH_DB_DUMP = PATH_DB_DUMP

View File

@@ -0,0 +1,8 @@
'use strict'
class RemoteTimeoutError extends Error {
constructor(remoteId) {
super('timeout while getting the remote ' + remoteId)
this.remoteId = remoteId
}
}
exports.RemoteTimeoutError = RemoteTimeoutError

View File

@@ -0,0 +1,87 @@
'use strict'
const { asyncMap } = require('@xen-orchestra/async-map')
const { createLogger } = require('@xen-orchestra/log')
const { Task } = require('../../Task.js')
const { debug, warn } = createLogger('xo:backups:AbstractVmBackup')
class AggregateError extends Error {
constructor(errors, message) {
super(message)
this.errors = errors
}
}
const asyncEach = async (iterable, fn, thisArg = iterable) => {
for (const item of iterable) {
await fn.call(thisArg, item)
}
}
exports.AbstractVmBackup = class AbstractVmBackup {
// calls fn for each function, warns of any errors, and throws only if there are no writers left
async _callWriters(fn, step, parallel = true) {
const writers = this._writers
const n = writers.size
if (n === 0) {
return
}
async function callWriter(writer) {
const { name } = writer.constructor
try {
debug('writer step starting', { step, writer: name })
await fn(writer)
debug('writer step succeeded', { duration: step, writer: name })
} catch (error) {
writers.delete(writer)
warn('writer step failed', { error, step, writer: name })
// these two steps are the only one that are not already in their own sub tasks
if (step === 'writer.checkBaseVdis()' || step === 'writer.beforeBackup()') {
Task.warning(
`the writer ${name} has failed the step ${step} with error ${error.message}. It won't be used anymore in this job execution.`
)
}
throw error
}
}
if (n === 1) {
const [writer] = writers
return callWriter(writer)
}
const errors = []
await (parallel ? asyncMap : asyncEach)(writers, async function (writer) {
try {
await callWriter(writer)
} catch (error) {
errors.push(error)
}
})
if (writers.size === 0) {
throw new AggregateError(errors, 'all targets have failed, step: ' + step)
}
}
async _healthCheck() {
const settings = this._settings
if (this._healthCheckSr === undefined) {
return
}
// check if current VM has tags
const { tags } = this.vm
const intersect = settings.healthCheckVmsWithTags.some(t => tags.includes(t))
if (settings.healthCheckVmsWithTags.length !== 0 && !intersect) {
return
}
await this._callWriters(writer => writer.healthCheck(this._healthCheckSr), 'writer.healthCheck()')
}
}

View File

@@ -0,0 +1,258 @@
'use strict'
const assert = require('assert')
const groupBy = require('lodash/groupBy.js')
const ignoreErrors = require('promise-toolbox/ignoreErrors')
const { asyncMap } = require('@xen-orchestra/async-map')
const { decorateMethodsWith } = require('@vates/decorate-with')
const { defer } = require('golike-defer')
const { formatDateTime } = require('@xen-orchestra/xapi')
const { getOldEntries } = require('../../_getOldEntries.js')
const { Task } = require('../../Task.js')
const { AbstractVmBackup } = require('./AbstractVMBackup.js')
class AbstractXapiVmBackup extends AbstractVmBackup {
constructor({
config,
getSnapshotNameLabel,
healthCheckSr,
job,
remoteAdapters,
remotes,
schedule,
settings,
srs,
throttleStream,
vm,
}) {
super()
if (vm.other_config['xo:backup:job'] === job.id && 'start' in vm.blocked_operations) {
// don't match replicated VMs created by this very job otherwise they
// will be replicated again and again
throw new Error('cannot backup a VM created by this very job')
}
this.config = config
this.job = job
this.remoteAdapters = remoteAdapters
this.scheduleId = schedule.id
this.timestamp = undefined
// VM currently backed up
this.vm = vm
const { tags } = this.vm
// VM (snapshot) that is really exported
this.exportedVm = undefined
this._fullVdisRequired = undefined
this._getSnapshotNameLabel = getSnapshotNameLabel
this._isIncremental = job.mode === 'delta'
this._healthCheckSr = healthCheckSr
this._jobId = job.id
this._jobSnapshots = undefined
this._throttleStream = throttleStream
this._xapi = vm.$xapi
// Base VM for the export
this._baseVm = undefined
// Settings for this specific run (job, schedule, VM)
if (tags.includes('xo-memory-backup')) {
settings.checkpointSnapshot = true
}
if (tags.includes('xo-offline-backup')) {
settings.offlineSnapshot = true
}
this._settings = settings
// Create writers
{
const writers = new Set()
this._writers = writers
const [BackupWriter, ReplicationWriter] = this._getWriters()
const allSettings = job.settings
Object.keys(remoteAdapters).forEach(remoteId => {
const targetSettings = {
...settings,
...allSettings[remoteId],
}
if (targetSettings.exportRetention !== 0) {
writers.add(new BackupWriter({ backup: this, remoteId, settings: targetSettings }))
}
})
srs.forEach(sr => {
const targetSettings = {
...settings,
...allSettings[sr.uuid],
}
if (targetSettings.copyRetention !== 0) {
writers.add(new ReplicationWriter({ backup: this, sr, settings: targetSettings }))
}
})
}
}
// ensure the VM itself does not have any backup metadata which would be
// copied on manual snapshots and interfere with the backup jobs
async _cleanMetadata() {
const { vm } = this
if ('xo:backup:job' in vm.other_config) {
await vm.update_other_config({
'xo:backup:datetime': null,
'xo:backup:deltaChainLength': null,
'xo:backup:exported': null,
'xo:backup:job': null,
'xo:backup:schedule': null,
'xo:backup:vm': null,
})
}
}
async _snapshot() {
const { vm } = this
const xapi = this._xapi
const settings = this._settings
if (this._mustDoSnapshot()) {
await Task.run({ name: 'snapshot' }, async () => {
if (!settings.bypassVdiChainsCheck) {
await vm.$assertHealthyVdiChains()
}
const snapshotRef = await vm[settings.checkpointSnapshot ? '$checkpoint' : '$snapshot']({
ignoreNobakVdis: true,
name_label: this._getSnapshotNameLabel(vm),
unplugVusbs: true,
})
this.timestamp = Date.now()
await xapi.setFieldEntries('VM', snapshotRef, 'other_config', {
'xo:backup:datetime': formatDateTime(this.timestamp),
'xo:backup:job': this._jobId,
'xo:backup:schedule': this.scheduleId,
'xo:backup:vm': vm.uuid,
})
this.exportedVm = await xapi.getRecord('VM', snapshotRef)
return this.exportedVm.uuid
})
} else {
this.exportedVm = vm
this.timestamp = Date.now()
}
}
async _fetchJobSnapshots() {
const jobId = this._jobId
const vmRef = this.vm.$ref
const xapi = this._xapi
const snapshotsRef = await xapi.getField('VM', vmRef, 'snapshots')
const snapshotsOtherConfig = await asyncMap(snapshotsRef, ref => xapi.getField('VM', ref, 'other_config'))
const snapshots = []
snapshotsOtherConfig.forEach((other_config, i) => {
if (other_config['xo:backup:job'] === jobId) {
snapshots.push({ other_config, $ref: snapshotsRef[i] })
}
})
snapshots.sort((a, b) => (a.other_config['xo:backup:datetime'] < b.other_config['xo:backup:datetime'] ? -1 : 1))
this._jobSnapshots = snapshots
}
async _removeUnusedSnapshots() {
const allSettings = this.job.settings
const baseSettings = this._baseSettings
const baseVmRef = this._baseVm?.$ref
const snapshotsPerSchedule = groupBy(this._jobSnapshots, _ => _.other_config['xo:backup:schedule'])
const xapi = this._xapi
await asyncMap(Object.entries(snapshotsPerSchedule), ([scheduleId, snapshots]) => {
const settings = {
...baseSettings,
...allSettings[scheduleId],
...allSettings[this.vm.uuid],
}
return asyncMap(getOldEntries(settings.snapshotRetention, snapshots), ({ $ref }) => {
if ($ref !== baseVmRef) {
return xapi.VM_destroy($ref)
}
})
})
}
async copy() {
throw new Error('Not implemented')
}
_getWriters() {
throw new Error('Not implemented')
}
_mustDoSnapshot() {
throw new Error('Not implemented')
}
async _selectBaseVm() {
throw new Error('Not implemented')
}
async run($defer) {
const settings = this._settings
assert(
!settings.offlineBackup || settings.snapshotRetention === 0,
'offlineBackup is not compatible with snapshotRetention'
)
await this._callWriters(async writer => {
await writer.beforeBackup()
$defer(async () => {
await writer.afterBackup()
})
}, 'writer.beforeBackup()')
await this._fetchJobSnapshots()
await this._selectBaseVm()
await this._cleanMetadata()
await this._removeUnusedSnapshots()
const { vm } = this
const isRunning = vm.power_state === 'Running'
const startAfter = isRunning && (settings.offlineBackup ? 'backup' : settings.offlineSnapshot && 'snapshot')
if (startAfter) {
await vm.$callAsync('clean_shutdown')
}
try {
await this._snapshot()
if (startAfter === 'snapshot') {
ignoreErrors.call(vm.$callAsync('start', false, false))
}
if (this._writers.size !== 0) {
await this._copy()
}
} finally {
if (startAfter) {
ignoreErrors.call(vm.$callAsync('start', false, false))
}
await this._fetchJobSnapshots()
await this._removeUnusedSnapshots()
}
await this._healthCheck()
}
}
exports.AbstractXapiVmBackup = AbstractXapiVmBackup
decorateMethodsWith(AbstractXapiVmBackup, {
run: defer,
})

View File

@@ -0,0 +1,61 @@
'use strict'
const { createLogger } = require('@xen-orchestra/log')
const { forkStreamUnpipe } = require('../forkStreamUnpipe.js')
const { FullRemoteWriter } = require('./writers/FullRemoteWriter.js')
const { FullXapiWriter } = require('./writers/FullXapiWriter.js')
const { watchStreamSize } = require('../../_watchStreamSize.js')
const { AbstractXapiVmBackup } = require('./AbstractXapiVmBackup.js')
const { debug } = createLogger('xo:backups:FullXapiVmBackup')
exports.FullXapiVmBackup = class FullXapiVmBackup extends AbstractXapiVmBackup {
_getWriters() {
return [FullRemoteWriter, FullXapiWriter]
}
_mustDoSnapshot() {
const { vm } = this
const settings = this._settings
return (
settings.unconditionalSnapshot ||
(!settings.offlineBackup && vm.power_state === 'Running') ||
settings.snapshotRetention !== 0
)
}
_selectBaseVm() {}
async _copy() {
const { compression } = this.job
const stream = this._throttleStream(
await this._xapi.VM_export(this.exportedVm.$ref, {
compress: Boolean(compression) && (compression === 'native' ? 'gzip' : 'zstd'),
useSnapshot: false,
})
)
const sizeContainer = watchStreamSize(stream)
const timestamp = Date.now()
await this._callWriters(
writer =>
writer.run({
sizeContainer,
stream: forkStreamUnpipe(stream),
timestamp,
}),
'writer.run()'
)
const { size } = sizeContainer
const end = Date.now()
const duration = end - timestamp
debug('transfer complete', {
duration,
speed: duration !== 0 ? (size * 1e3) / 1024 / 1024 / duration : 0,
size,
})
}
}

View File

@@ -0,0 +1,170 @@
'use strict'
const findLast = require('lodash/findLast.js')
const keyBy = require('lodash/keyBy.js')
const mapValues = require('lodash/mapValues.js')
const vhdStreamValidator = require('vhd-lib/vhdStreamValidator.js')
const { asyncMap } = require('@xen-orchestra/async-map')
const { createLogger } = require('@xen-orchestra/log')
const { pipeline } = require('node:stream')
const { IncrementalRemoteWriter } = require('./writers/IncrementalRemoteWriter.js')
const { IncrementalXapiWriter } = require('./writers/IncrementalXapiWriter.js')
const { exportIncrementalVm } = require('../../_incrementalVm.js')
const { forkStreamUnpipe } = require('../forkStreamUnpipe.js')
const { Task } = require('../../Task.js')
const { watchStreamSize } = require('../../_watchStreamSize.js')
const { AbstractXapiVmBackup } = require('./AbstractXapiVmBackup.js')
const { debug } = createLogger('xo:backups:IncrementalXapiVmBackup')
const forkDeltaExport = deltaExport =>
Object.create(deltaExport, {
streams: {
value: mapValues(deltaExport.streams, forkStreamUnpipe),
},
})
const noop = Function.prototype
exports.IncrementalXapiVmBackup = class IncrementalXapiVmBackup extends AbstractXapiVmBackup {
_getWriters() {
return [IncrementalRemoteWriter, IncrementalXapiWriter]
}
_mustDoSnapshot() {
return true
}
async _copy() {
const { exportedVm } = this
const baseVm = this._baseVm
const fullVdisRequired = this._fullVdisRequired
const isFull = fullVdisRequired === undefined || fullVdisRequired.size !== 0
await this._callWriters(writer => writer.prepare({ isFull }), 'writer.prepare()')
const deltaExport = await exportIncrementalVm(exportedVm, baseVm, {
fullVdisRequired,
})
// since NBD is network based, if one disk use nbd , all the disk use them
// except the suspended VDI
if (Object.values(deltaExport.streams).some(({ _nbd }) => _nbd)) {
Task.info('Transfer data using NBD')
}
const sizeContainers = mapValues(deltaExport.streams, stream => watchStreamSize(stream))
if (this._settings.validateVhdStreams) {
deltaExport.streams = mapValues(deltaExport.streams, stream => pipeline(stream, vhdStreamValidator, noop))
}
deltaExport.streams = mapValues(deltaExport.streams, this._throttleStream)
const timestamp = Date.now()
await this._callWriters(
writer =>
writer.transfer({
deltaExport: forkDeltaExport(deltaExport),
sizeContainers,
timestamp,
}),
'writer.transfer()'
)
this._baseVm = exportedVm
if (baseVm !== undefined) {
await exportedVm.update_other_config(
'xo:backup:deltaChainLength',
String(+(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1)
)
}
// not the case if offlineBackup
if (exportedVm.is_a_snapshot) {
await exportedVm.update_other_config('xo:backup:exported', 'true')
}
const size = Object.values(sizeContainers).reduce((sum, { size }) => sum + size, 0)
const end = Date.now()
const duration = end - timestamp
debug('transfer complete', {
duration,
speed: duration !== 0 ? (size * 1e3) / 1024 / 1024 / duration : 0,
size,
})
await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
}
async _selectBaseVm() {
const xapi = this._xapi
let baseVm = findLast(this._jobSnapshots, _ => 'xo:backup:exported' in _.other_config)
if (baseVm === undefined) {
debug('no base VM found')
return
}
const fullInterval = this._settings.fullInterval
const deltaChainLength = +(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1
if (!(fullInterval === 0 || fullInterval > deltaChainLength)) {
debug('not using base VM becaust fullInterval reached')
return
}
const srcVdis = keyBy(await xapi.getRecords('VDI', await this.vm.$getDisks()), '$ref')
// resolve full record
baseVm = await xapi.getRecord('VM', baseVm.$ref)
const baseUuidToSrcVdi = new Map()
await asyncMap(await baseVm.$getDisks(), async baseRef => {
const [baseUuid, snapshotOf] = await Promise.all([
xapi.getField('VDI', baseRef, 'uuid'),
xapi.getField('VDI', baseRef, 'snapshot_of'),
])
const srcVdi = srcVdis[snapshotOf]
if (srcVdi !== undefined) {
baseUuidToSrcVdi.set(baseUuid, srcVdi)
} else {
debug('ignore snapshot VDI because no longer present on VM', {
vdi: baseUuid,
})
}
})
const presentBaseVdis = new Map(baseUuidToSrcVdi)
await this._callWriters(
writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis, baseVm),
'writer.checkBaseVdis()',
false
)
if (presentBaseVdis.size === 0) {
debug('no base VM found')
return
}
const fullVdisRequired = new Set()
baseUuidToSrcVdi.forEach((srcVdi, baseUuid) => {
if (presentBaseVdis.has(baseUuid)) {
debug('found base VDI', {
base: baseUuid,
vdi: srcVdi.uuid,
})
} else {
debug('missing base VDI', {
base: baseUuid,
vdi: srcVdi.uuid,
})
fullVdisRequired.add(srcVdi.uuid)
}
})
this._baseVm = baseVm
this._fullVdisRequired = fullVdisRequired
}
}

View File

@@ -1,13 +1,13 @@
'use strict'
const { formatFilenameDate } = require('../_filenameDate.js')
const { getOldEntries } = require('../_getOldEntries.js')
const { Task } = require('../Task.js')
const { formatFilenameDate } = require('../../../_filenameDate.js')
const { getOldEntries } = require('../../../_getOldEntries.js')
const { Task } = require('../../../Task.js')
const { MixinBackupWriter } = require('./_MixinBackupWriter.js')
const { MixinRemoteWriter } = require('./_MixinRemoteWriter.js')
const { AbstractFullWriter } = require('./_AbstractFullWriter.js')
exports.FullBackupWriter = class FullBackupWriter extends MixinBackupWriter(AbstractFullWriter) {
exports.FullRemoteWriter = class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
constructor(props) {
super(props)

View File

@@ -4,15 +4,15 @@ const ignoreErrors = require('promise-toolbox/ignoreErrors')
const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map')
const { formatDateTime } = require('@xen-orchestra/xapi')
const { formatFilenameDate } = require('../_filenameDate.js')
const { getOldEntries } = require('../_getOldEntries.js')
const { Task } = require('../Task.js')
const { formatFilenameDate } = require('../../../_filenameDate.js')
const { getOldEntries } = require('../../../_getOldEntries.js')
const { Task } = require('../../../Task.js')
const { AbstractFullWriter } = require('./_AbstractFullWriter.js')
const { MixinReplicationWriter } = require('./_MixinReplicationWriter.js')
const { MixinXapiWriter } = require('./_MixinXapiWriter.js')
const { listReplicatedVms } = require('./_listReplicatedVms.js')
exports.FullReplicationWriter = class FullReplicationWriter extends MixinReplicationWriter(AbstractFullWriter) {
exports.FullXapiWriter = class FullXapiWriter extends MixinXapiWriter(AbstractFullWriter) {
constructor(props) {
super(props)
@@ -21,6 +21,7 @@ exports.FullReplicationWriter = class FullReplicationWriter extends MixinReplica
name: 'export',
data: {
id: props.sr.uuid,
name_label: this._sr.name_label,
type: 'SR',
// necessary?

View File

@@ -11,20 +11,19 @@ const { decorateClass } = require('@vates/decorate-with')
const { defer } = require('golike-defer')
const { dirname } = require('path')
const { formatFilenameDate } = require('../_filenameDate.js')
const { getOldEntries } = require('../_getOldEntries.js')
const { Task } = require('../Task.js')
const { formatFilenameDate } = require('../../../_filenameDate.js')
const { getOldEntries } = require('../../../_getOldEntries.js')
const { Task } = require('../../../Task.js')
const { MixinBackupWriter } = require('./_MixinBackupWriter.js')
const { AbstractDeltaWriter } = require('./_AbstractDeltaWriter.js')
const { MixinRemoteWriter } = require('./_MixinRemoteWriter.js')
const { AbstractIncrementalWriter } = require('./_AbstractIncrementalWriter.js')
const { checkVhd } = require('./_checkVhd.js')
const { packUuid } = require('./_packUuid.js')
const { Disposable } = require('promise-toolbox')
const NbdClient = require('@vates/nbd-client')
const { debug, warn, info } = createLogger('xo:backups:DeltaBackupWriter')
const { warn } = createLogger('xo:backups:DeltaBackupWriter')
class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrementalWriter) {
async checkBaseVdis(baseUuidToSrcVdi) {
const { handler } = this._adapter
const backup = this._backup
@@ -200,41 +199,12 @@ class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
await checkVhd(handler, parentPath)
}
const vdiRef = vm.$xapi.getObject(vdi.uuid).$ref
let nbdClient
if (this._backup.config.useNbd && adapter.useVhdDirectory()) {
debug('useNbd is enabled', { vdi: id, path })
// get nbd if possible
try {
// this will always take the first host in the list
const [nbdInfo] = await vm.$xapi.call('VDI.get_nbd_info', vdiRef)
debug('got NBD info', { nbdInfo, vdi: id, path })
nbdClient = new NbdClient(nbdInfo)
await nbdClient.connect()
// this will inform the xapi that we don't need this anymore
// and will detach the vdi from dom0
$defer(() => nbdClient.disconnect())
info('NBD client ready', { vdi: id, path })
Task.info('NBD used')
} catch (error) {
Task.warning('NBD configured but unusable', { error })
nbdClient = undefined
warn('error connecting to NBD server', { error, vdi: id, path })
}
} else {
debug('useNbd is disabled', { vdi: id, path })
}
transferSize += await adapter.writeVhd(path, deltaExport.streams[`${id}.vhd`], {
// no checksum for VHDs, because they will be invalidated by
// merges and chainings
checksum: false,
validator: tmpPath => checkVhd(handler, tmpPath),
writeBlockConcurrency: this._backup.config.writeBlockConcurrency,
nbdClient,
})
if (isDelta) {
@@ -257,6 +227,6 @@ class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
// TODO: run cleanup?
}
}
exports.DeltaBackupWriter = decorateClass(DeltaBackupWriter, {
exports.IncrementalRemoteWriter = decorateClass(IncrementalRemoteWriter, {
_transfer: defer,
})

View File

@@ -4,16 +4,16 @@ const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map')
const ignoreErrors = require('promise-toolbox/ignoreErrors')
const { formatDateTime } = require('@xen-orchestra/xapi')
const { formatFilenameDate } = require('../_filenameDate.js')
const { getOldEntries } = require('../_getOldEntries.js')
const { importDeltaVm, TAG_COPY_SRC } = require('../_deltaVm.js')
const { Task } = require('../Task.js')
const { formatFilenameDate } = require('../../../_filenameDate.js')
const { getOldEntries } = require('../../../_getOldEntries.js')
const { importIncrementalVm, TAG_COPY_SRC } = require('../../../_incrementalVm.js')
const { Task } = require('../../../Task.js')
const { AbstractDeltaWriter } = require('./_AbstractDeltaWriter.js')
const { MixinReplicationWriter } = require('./_MixinReplicationWriter.js')
const { AbstractIncrementalWriter } = require('./_AbstractIncrementalWriter.js')
const { MixinXapiWriter } = require('./_MixinXapiWriter.js')
const { listReplicatedVms } = require('./_listReplicatedVms.js')
exports.DeltaReplicationWriter = class DeltaReplicationWriter extends MixinReplicationWriter(AbstractDeltaWriter) {
exports.IncrementalXapiWriter = class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
async checkBaseVdis(baseUuidToSrcVdi, baseVm) {
const sr = this._sr
const replicatedVm = listReplicatedVms(sr.$xapi, this._backup.job.id, sr.uuid, this._backup.vm.uuid).find(
@@ -45,11 +45,13 @@ exports.DeltaReplicationWriter = class DeltaReplicationWriter extends MixinRepli
data: {
id: this._sr.uuid,
isFull,
name_label: this._sr.name_label,
type: 'SR',
},
})
this.transfer = task.wrapFn(this.transfer)
this.cleanup = task.wrapFn(this.cleanup, true)
this.cleanup = task.wrapFn(this.cleanup)
this.healthCheck = task.wrapFn(this.healthCheck, true)
return task.run(() => this._prepare())
}
@@ -88,7 +90,7 @@ exports.DeltaReplicationWriter = class DeltaReplicationWriter extends MixinRepli
let targetVmRef
await Task.run({ name: 'transfer' }, async () => {
targetVmRef = await importDeltaVm(
targetVmRef = await importIncrementalVm(
{
__proto__: deltaExport,
vm: {

View File

@@ -2,7 +2,7 @@
const { AbstractWriter } = require('./_AbstractWriter.js')
exports.AbstractDeltaWriter = class AbstractDeltaWriter extends AbstractWriter {
exports.AbstractIncrementalWriter = class AbstractIncrementalWriter extends AbstractWriter {
checkBaseVdis(baseUuidToSrcVdi, baseVm) {
throw new Error('Not implemented')
}

View File

@@ -4,17 +4,17 @@ const { createLogger } = require('@xen-orchestra/log')
const { join } = require('path')
const assert = require('assert')
const { formatFilenameDate } = require('../_filenameDate.js')
const { getVmBackupDir } = require('../_getVmBackupDir.js')
const { HealthCheckVmBackup } = require('../HealthCheckVmBackup.js')
const { ImportVmBackup } = require('../ImportVmBackup.js')
const { Task } = require('../Task.js')
const MergeWorker = require('../merge-worker/index.js')
const { formatFilenameDate } = require('../../../_filenameDate.js')
const { getVmBackupDir } = require('../../../_getVmBackupDir.js')
const { HealthCheckVmBackup } = require('../../../HealthCheckVmBackup.js')
const { ImportVmBackup } = require('../../../ImportVmBackup.js')
const { Task } = require('../../../Task.js')
const MergeWorker = require('../../../merge-worker/index.js')
const { info, warn } = createLogger('xo:backups:MixinBackupWriter')
exports.MixinBackupWriter = (BaseClass = Object) =>
class MixinBackupWriter extends BaseClass {
exports.MixinRemoteWriter = (BaseClass = Object) =>
class MixinRemoteWriter extends BaseClass {
#lock
constructor({ remoteId, ...rest }) {
@@ -80,7 +80,7 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
assert.notStrictEqual(
this._metadataFileName,
undefined,
'Metadata file name should be defined before making a healthcheck'
'Metadata file name should be defined before making a health check'
)
return Task.run(
{

View File

@@ -1,8 +1,8 @@
'use strict'
const { Task } = require('../Task')
const { Task } = require('../../../Task')
const assert = require('node:assert/strict')
const { HealthCheckVmBackup } = require('../HealthCheckVmBackup')
const { HealthCheckVmBackup } = require('../../../HealthCheckVmBackup')
function extractOpaqueRef(str) {
const OPAQUE_REF_RE = /OpaqueRef:[0-9a-z-]+/
@@ -12,8 +12,8 @@ function extractOpaqueRef(str) {
}
return matches[0]
}
exports.MixinReplicationWriter = (BaseClass = Object) =>
class MixinReplicationWriter extends BaseClass {
exports.MixinXapiWriter = (BaseClass = Object) =>
class MixinXapiWriter extends BaseClass {
constructor({ sr, ...rest }) {
super(rest)

View File

@@ -0,0 +1,138 @@
'use strict'
const { asyncMapSettled } = require('@xen-orchestra/async-map')
const Disposable = require('promise-toolbox/Disposable')
const { limitConcurrency } = require('limit-concurrency-decorator')
const { extractIdsFromSimplePattern } = require('../extractIdsFromSimplePattern.js')
const { Task } = require('../Task.js')
const { createStreamThrottle } = require('./createStreamThrottle.js')
const { DEFAULT_SETTINGS, AbstractBackupJob } = require('./AbstractBackupJob.js')
const { runTask } = require('./runTask.js')
const { getAdaptersByRemote } = require('./getAdaptersByRemote.js')
const { IncrementalXapiVmBackup } = require('./VmBackup/IncrementalXapiVmBackup.js')
const { FullXapiVmBackup } = require('./VmBackup/FullXapiVmBackup.js')
const DEFAULT_XAPI_VM_SETTINGS = {
bypassVdiChainsCheck: false,
checkpointSnapshot: false,
concurrency: 2,
copyRetention: 0,
deleteFirst: false,
exportRetention: 0,
fullInterval: 0,
healthCheckSr: undefined,
healthCheckVmsWithTags: [],
maxExportRate: 0,
maxMergedDeltasPerRun: Infinity,
offlineBackup: false,
offlineSnapshot: false,
snapshotRetention: 0,
timeout: 0,
useNbd: false,
unconditionalSnapshot: false,
validateVhdStreams: false,
vmTimeout: 0,
}
exports.XapiVmBackupJob = class XapiVmBackupJob extends AbstractBackupJob {
_computeBaseSettings(config, job) {
const baseSettings = { ...DEFAULT_SETTINGS }
Object.assign(baseSettings, DEFAULT_XAPI_VM_SETTINGS, config.defaultSettings, config.vm?.defaultSettings)
Object.assign(baseSettings, job.settings[''])
return baseSettings
}
async run() {
const job = this._job
// FIXME: proper SimpleIdPattern handling
const getSnapshotNameLabel = this._getSnapshotNameLabel
const schedule = this._schedule
const settings = this._settings
const throttleStream = createStreamThrottle(settings.maxExportRate)
const config = this._config
await Disposable.use(
Disposable.all(
extractIdsFromSimplePattern(job.srs).map(id =>
this._getRecord('SR', id).catch(error => {
runTask(
{
name: 'get SR record',
data: { type: 'SR', id },
},
() => Promise.reject(error)
)
})
)
),
Disposable.all(extractIdsFromSimplePattern(job.remotes).map(id => this._getAdapter(id))),
() => (settings.healthCheckSr !== undefined ? this._getRecord('SR', settings.healthCheckSr) : undefined),
async (srs, remoteAdapters, healthCheckSr) => {
// remove adapters that failed (already handled)
remoteAdapters = remoteAdapters.filter(_ => _ !== undefined)
// remove srs that failed (already handled)
srs = srs.filter(_ => _ !== undefined)
if (remoteAdapters.length === 0 && srs.length === 0 && settings.snapshotRetention === 0) {
return
}
const vmIds = extractIdsFromSimplePattern(job.vms)
Task.info('vms', { vms: vmIds })
remoteAdapters = getAdaptersByRemote(remoteAdapters)
const allSettings = this._job.settings
const baseSettings = this._baseSettings
const handleVm = vmUuid => {
const taskStart = { name: 'backup VM', data: { type: 'VM', id: vmUuid } }
return this._getRecord('VM', vmUuid).then(
disposableVm =>
Disposable.use(disposableVm, vm => {
taskStart.data.name_label = vm.name_label
return runTask(taskStart, () => {
const opts = {
baseSettings,
config,
getSnapshotNameLabel,
healthCheckSr,
job,
remoteAdapters,
schedule,
settings: { ...settings, ...allSettings[vm.uuid] },
srs,
throttleStream,
vm,
}
let vmBackup
if (job.mode === 'delta') {
vmBackup = new IncrementalXapiVmBackup(opts)
} else {
if (job.mode === 'full') {
vmBackup = new FullXapiVmBackup(opts)
} else {
throw new Error(`Job mode ${job.mode} not implemented`)
}
}
return vmBackup.run()
})
}),
error =>
runTask(taskStart, () => {
throw error
})
)
}
const { concurrency } = settings
await asyncMapSettled(vmIds, concurrency === 0 ? handleVm : limitConcurrency(concurrency)(handleVm))
}
)
}
}

View File

@@ -2,9 +2,9 @@
const { asyncMap } = require('@xen-orchestra/async-map')
const { DIR_XO_CONFIG_BACKUPS } = require('./RemoteAdapter.js')
const { formatFilenameDate } = require('./_filenameDate.js')
const { Task } = require('./Task.js')
const { DIR_XO_CONFIG_BACKUPS } = require('../RemoteAdapter.js')
const { formatFilenameDate } = require('../_filenameDate.js')
const { Task } = require('../Task.js')
exports.XoMetadataBackup = class XoMetadataBackup {
constructor({ config, job, remoteAdapters, schedule, settings }) {

View File

@@ -6,7 +6,7 @@ const identity = require('lodash/identity.js')
const noop = Function.prototype
module.exports = function createStreamThrottle(rate) {
exports.createStreamThrottle = function createStreamThrottle(rate) {
if (rate === 0) {
return identity
}

View File

@@ -0,0 +1,9 @@
'use strict'
const getAdaptersByRemote = adapters => {
const adaptersByRemote = {}
adapters.forEach(({ adapter, remoteId }) => {
adaptersByRemote[remoteId] = adapter
})
return adaptersByRemote
}
exports.getAdaptersByRemote = getAdaptersByRemote

View File

@@ -0,0 +1,6 @@
'use strict'
const { Task } = require('../Task.js')
const noop = Function.prototype
const runTask = (...args) => Task.run(...args).catch(noop) // errors are handled by logs
exports.runTask = runTask

View File

@@ -13,10 +13,10 @@ const { createDebounceResource } = require('@vates/disposable/debounceResource.j
const { decorateMethodsWith } = require('@vates/decorate-with')
const { deduped } = require('@vates/disposable/deduped.js')
const { getHandler } = require('@xen-orchestra/fs')
const { instantiateBackupJob } = require('./backupJob.js')
const { parseDuration } = require('@vates/parse-duration')
const { Xapi } = require('@xen-orchestra/xapi')
const { Backup } = require('./Backup.js')
const { RemoteAdapter } = require('./RemoteAdapter.js')
const { Task } = require('./Task.js')
@@ -48,7 +48,7 @@ class BackupWorker {
}
run() {
return new Backup({
return instantiateBackupJob({
config: this.#config,
getAdapter: remoteId => this.getAdapter(this.#remotes[remoteId]),
getConnectedRecord: Disposable.factory(async function* getConnectedRecord(type, uuid) {

View File

@@ -3,7 +3,6 @@
const { beforeEach, afterEach, test, describe } = require('test')
const assert = require('assert').strict
const rimraf = require('rimraf')
const tmp = require('tmp')
const fs = require('fs-extra')
const uuid = require('uuid')
@@ -14,6 +13,7 @@ const { VHDFOOTER, VHDHEADER } = require('./tests.fixtures.js')
const { VhdFile, Constants, VhdDirectory, VhdAbstract } = require('vhd-lib')
const { checkAliases } = require('./_cleanVm')
const { dirname, basename } = require('path')
const { rimraf } = require('rimraf')
let tempDir, adapter, handler, jobId, vdiId, basePath, relativePath
const rootPath = 'xo-vm-backups/VMUUID/'

View File

@@ -33,7 +33,7 @@ const resolveUuid = async (xapi, cache, uuid, type) => {
return ref
}
exports.exportDeltaVm = async function exportDeltaVm(
exports.exportIncrementalVm = async function exportIncrementalVm(
vm,
baseVm,
{
@@ -143,18 +143,18 @@ exports.exportDeltaVm = async function exportDeltaVm(
)
}
exports.importDeltaVm = defer(async function importDeltaVm(
exports.importIncrementalVm = defer(async function importIncrementalVm(
$defer,
deltaVm,
incrementalVm,
sr,
{ cancelToken = CancelToken.none, detectBase = true, mapVdisSrs = {}, newMacAddresses = false } = {}
) {
const { version } = deltaVm
const { version } = incrementalVm
if (compareVersions(version, '1.0.0') < 0) {
throw new Error(`Unsupported delta backup version: ${version}`)
}
const vmRecord = deltaVm.vm
const vmRecord = incrementalVm.vm
const xapi = sr.$xapi
let baseVm
@@ -183,15 +183,15 @@ exports.importDeltaVm = defer(async function importDeltaVm(
baseVdis[vbd.VDI] = vbd.$VDI
}
})
const vdiRecords = deltaVm.vdis
const vdiRecords = incrementalVm.vdis
// 0. Create suspend_VDI
let suspendVdi
if (vmRecord.power_state === 'Suspended') {
if (vmRecord.suspend_VDI !== undefined && vmRecord.suspend_VDI !== 'OpaqueRef:NULL') {
const vdi = vdiRecords[vmRecord.suspend_VDI]
if (vdi === undefined) {
Task.warning('Suspend VDI not available for this suspended VM', {
vm: pick(vmRecord, 'uuid', 'name_label'),
vm: pick(vmRecord, 'uuid', 'name_label', 'suspend_VDI'),
})
} else {
suspendVdi = await xapi.getRecord(
@@ -240,7 +240,7 @@ exports.importDeltaVm = defer(async function importDeltaVm(
await asyncMap(await xapi.getField('VM', vmRef, 'VBDs'), ref => ignoreErrors.call(xapi.call('VBD.destroy', ref)))
// 3. Create VDIs & VBDs.
const vbdRecords = deltaVm.vbds
const vbdRecords = incrementalVm.vbds
const vbds = groupBy(vbdRecords, 'VDI')
const newVdis = {}
await asyncMap(Object.keys(vdiRecords), async vdiRef => {
@@ -309,7 +309,7 @@ exports.importDeltaVm = defer(async function importDeltaVm(
}
})
const { streams } = deltaVm
const { streams } = incrementalVm
await Promise.all([
// Import VDI contents.
@@ -326,7 +326,7 @@ exports.importDeltaVm = defer(async function importDeltaVm(
}),
// Create VIFs.
asyncMap(Object.values(deltaVm.vifs), vif => {
asyncMap(Object.values(incrementalVm.vifs), vif => {
let network = vif.$network$uuid && xapi.getObjectByUuid(vif.$network$uuid, undefined)
if (network === undefined) {
@@ -358,8 +358,8 @@ exports.importDeltaVm = defer(async function importDeltaVm(
])
await Promise.all([
deltaVm.vm.ha_always_run && xapi.setField('VM', vmRef, 'ha_always_run', true),
xapi.setField('VM', vmRef, 'name_label', deltaVm.vm.name_label),
incrementalVm.vm.ha_always_run && xapi.setField('VM', vmRef, 'ha_always_run', true),
xapi.setField('VM', vmRef, 'name_label', incrementalVm.vm.name_label),
])
return vmRef

View File

@@ -0,0 +1,21 @@
'use strict'
const { MetadatasBackupJob } = require('./_backupJob/MetadatasBackupJob.js')
const { XapiVmBackupJob } = require('./_backupJob/XapiVMBackupJobs.js')
exports.instantiateBackupJob = function instantiateBackupJob({
config,
getAdapter,
getConnectedRecord,
job,
schedule,
}) {
switch (job.type) {
case 'backup':
return new XapiVmBackupJob({ config, getAdapter, getConnectedRecord, job, schedule })
case 'metadataBackup':
return new MetadatasBackupJob({ config, getAdapter, getConnectedRecord, job, schedule })
default:
throw new Error(`No runner for the backup type ${job.type}`)
}
}

View File

@@ -94,13 +94,13 @@ In case any incoherence is detected, the file is deleted so it will be fully gen
job.start(data: { mode: Mode, reportWhen: ReportWhen })
├─ task.info(message: 'vms', data: { vms: string[] })
├─ task.warning(message: string)
├─ task.start(data: { type: 'VM', id: string })
├─ task.start(data: { type: 'VM', id: string, name_label?: string })
│ ├─ task.warning(message: string)
| ├─ task.start(message: 'clean-vm')
│ │ └─ task.end
│ ├─ task.start(message: 'snapshot')
│ │ └─ task.end
│ ├─ task.start(message: 'export', data: { type: 'SR' | 'remote', id: string, isFull: boolean })
│ ├─ task.start(message: 'export', data: { type: 'SR' | 'remote', id: string, name_label?: string, isFull: boolean })
│ │ ├─ task.warning(message: string)
│ │ ├─ task.start(message: 'transfer')
│ │ │ ├─ task.warning(message: string)

View File

@@ -0,0 +1,35 @@
#!/bin/sh
# This script must be executed at the start of the machine.
#
# It must run as root to be able to use xenstore-read and xenstore-write
# fail in case of error or undefined variable
set -eu
# stop there if a health check is not in progress
if [ "$(xenstore-read vm-data/xo-backup-health-check 2>&1)" != planned ]
then
exit
fi
# not necessary, but informs XO that this script has started which helps diagnose issues
xenstore-write vm-data/xo-backup-health-check running
# put your test here
#
# in this example, the command `sqlite3` is used to validate the health of a database
# and its output is captured and passed to XO via the XenStore in case of error
if output=$(sqlite3 ~/my-database.sqlite3 .table 2>&1)
then
# inform XO everything is ok
xenstore-write vm-data/xo-backup-health-check success
else
# inform XO there is an issue
xenstore-write vm-data/xo-backup-health-check failure
# more info about the issue can be written to `vm-data/health-check-error`
#
# it will be shown in XO
xenstore-write vm-data/xo-backup-health-check-error "$output"
fi

View File

@@ -8,13 +8,13 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.32.0",
"version": "0.36.1",
"engines": {
"node": ">=14.6"
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node--test"
"test-integration": "node--test *.integ.js"
},
"dependencies": {
"@kldzj/stream-throttle": "^1.1.1",
@@ -24,10 +24,10 @@
"@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.4",
"@vates/fuse-vhd": "^1.0.0",
"@vates/nbd-client": "^1.0.1",
"@vates/nbd-client": "^1.2.0",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^3.3.2",
"@xen-orchestra/fs": "^3.3.4",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/template": "^0.1.0",
"compare-versions": "^5.0.1",
@@ -42,17 +42,17 @@
"promise-toolbox": "^0.21.0",
"proper-lockfile": "^4.1.2",
"uuid": "^9.0.0",
"vhd-lib": "^4.2.1",
"vhd-lib": "^4.4.0",
"yazl": "^2.5.1"
},
"devDependencies": {
"rimraf": "^4.1.1",
"rimraf": "^5.0.1",
"sinon": "^15.0.1",
"test": "^3.2.1",
"tmp": "^0.2.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^2.0.0"
"@xen-orchestra/xapi": "^2.2.0"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

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

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/fs",
"version": "3.3.2",
"version": "3.3.4",
"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",
@@ -29,7 +29,7 @@
"@vates/async-each": "^1.0.0",
"@vates/coalesce-calls": "^0.1.0",
"@vates/decorate-with": "^2.0.0",
"@vates/read-chunk": "^1.0.1",
"@vates/read-chunk": "^1.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"bind-property-descriptor": "^2.0.0",
@@ -53,7 +53,9 @@
"@babel/preset-env": "^7.8.0",
"cross-env": "^7.0.2",
"dotenv": "^16.0.0",
"rimraf": "^4.1.1",
"rimraf": "^5.0.1",
"sinon": "^15.0.4",
"test": "^3.3.0",
"tmp": "^0.2.1"
},
"scripts": {
@@ -63,7 +65,9 @@
"prebuild": "yarn run clean",
"predev": "yarn run clean",
"prepublishOnly": "yarn run build",
"postversion": "npm publish"
"pretest": "yarn run build",
"postversion": "npm publish",
"test": "node--test ./dist/"
},
"author": {
"name": "Vates SAS",

View File

@@ -1,4 +1,5 @@
/* eslint-env jest */
import { describe, it } from 'test'
import { strict as assert } from 'assert'
import { Readable } from 'readable-stream'
import copyStreamToBuffer from './_copyStreamToBuffer.js'
@@ -16,6 +17,6 @@ describe('copyStreamToBuffer', () => {
await copyStreamToBuffer(stream, buffer)
expect(buffer.toString()).toBe('hel')
assert.equal(buffer.toString(), 'hel')
})
})

View File

@@ -1,4 +1,5 @@
/* eslint-env jest */
import { describe, it } from 'test'
import { strict as assert } from 'assert'
import { Readable } from 'readable-stream'
import createBufferFromStream from './_createBufferFromStream.js'
@@ -14,6 +15,6 @@ describe('createBufferFromStream', () => {
const buffer = await createBufferFromStream(stream)
expect(buffer.toString()).toBe('hello')
assert.equal(buffer.toString(), 'hello')
})
})

View File

@@ -1,4 +1,6 @@
/* eslint-env jest */
import { describe, it } from 'test'
import { strict as assert } from 'assert'
import { Readable } from 'node:stream'
import { _getEncryptor } from './_encryptor'
import crypto from 'crypto'
@@ -25,13 +27,13 @@ algorithms.forEach(algorithm => {
it('handle buffer', () => {
const encrypted = encryptor.encryptData(buffer)
if (algorithm !== 'none') {
expect(encrypted.equals(buffer)).toEqual(false) // encrypted should be different
assert.equal(encrypted.equals(buffer), false) // encrypted should be different
// ivlength, auth tag, padding
expect(encrypted.length).not.toEqual(buffer.length)
assert.notEqual(encrypted.length, buffer.length)
}
const decrypted = encryptor.decryptData(encrypted)
expect(decrypted.equals(buffer)).toEqual(true)
assert.equal(decrypted.equals(buffer), true)
})
it('handle stream', async () => {
@@ -39,12 +41,12 @@ algorithms.forEach(algorithm => {
stream.length = buffer.length
const encrypted = encryptor.encryptStream(stream)
if (algorithm !== 'none') {
expect(encrypted.length).toEqual(undefined)
assert.equal(encrypted.length, undefined)
}
const decrypted = encryptor.decryptStream(encrypted)
const decryptedBuffer = await streamToBuffer(decrypted)
expect(decryptedBuffer.equals(buffer)).toEqual(true)
assert.equal(decryptedBuffer.equals(buffer), true)
})
})
})

View File

@@ -1,4 +1,5 @@
/* eslint-env jest */
import { describe, it } from 'test'
import { strict as assert } from 'assert'
import guessAwsRegion from './_guessAwsRegion.js'
@@ -6,12 +7,12 @@ describe('guessAwsRegion', () => {
it('should return region from AWS URL', async () => {
const region = guessAwsRegion('s3.test-region.amazonaws.com')
expect(region).toBe('test-region')
assert.equal(region, 'test-region')
})
it('should return default region if none is found is AWS URL', async () => {
const region = guessAwsRegion('s3.amazonaws.com')
expect(region).toBe('us-east-1')
assert.equal(region, 'us-east-1')
})
})

View File

@@ -1,11 +1,13 @@
/* eslint-env jest */
import { after, beforeEach, describe, it } from 'test'
import { strict as assert } from 'assert'
import sinon from 'sinon'
import { DEFAULT_ENCRYPTION_ALGORITHM, _getEncryptor } from './_encryptor'
import { Disposable, pFromCallback, TimeoutError } from 'promise-toolbox'
import { getSyncedHandler } from '.'
import { rimraf } from 'rimraf'
import AbstractHandler from './abstract'
import fs from 'fs-extra'
import rimraf from 'rimraf'
import tmp from 'tmp'
const TIMEOUT = 10e3
@@ -24,7 +26,7 @@ class TestHandler extends AbstractHandler {
const noop = Function.prototype
jest.useFakeTimers()
const clock = sinon.useFakeTimers()
describe('closeFile()', () => {
it(`throws in case of timeout`, async () => {
@@ -33,8 +35,8 @@ describe('closeFile()', () => {
})
const promise = testHandler.closeFile({ fd: undefined, path: '' })
jest.advanceTimersByTime(TIMEOUT)
await expect(promise).rejects.toThrowError(TimeoutError)
clock.tick(TIMEOUT)
await assert.rejects(promise, TimeoutError)
})
})
@@ -45,8 +47,8 @@ describe('getInfo()', () => {
})
const promise = testHandler.getInfo()
jest.advanceTimersByTime(TIMEOUT)
await expect(promise).rejects.toThrowError(TimeoutError)
clock.tick(TIMEOUT)
await assert.rejects(promise, TimeoutError)
})
})
@@ -57,8 +59,8 @@ describe('getSize()', () => {
})
const promise = testHandler.getSize('')
jest.advanceTimersByTime(TIMEOUT)
await expect(promise).rejects.toThrowError(TimeoutError)
clock.tick(TIMEOUT)
await assert.rejects(promise, TimeoutError)
})
})
@@ -69,8 +71,8 @@ describe('list()', () => {
})
const promise = testHandler.list('.')
jest.advanceTimersByTime(TIMEOUT)
await expect(promise).rejects.toThrowError(TimeoutError)
clock.tick(TIMEOUT)
await assert.rejects(promise, TimeoutError)
})
})
@@ -81,8 +83,8 @@ describe('openFile()', () => {
})
const promise = testHandler.openFile('path')
jest.advanceTimersByTime(TIMEOUT)
await expect(promise).rejects.toThrowError(TimeoutError)
clock.tick(TIMEOUT)
await assert.rejects(promise, TimeoutError)
})
})
@@ -93,8 +95,8 @@ describe('rename()', () => {
})
const promise = testHandler.rename('oldPath', 'newPath')
jest.advanceTimersByTime(TIMEOUT)
await expect(promise).rejects.toThrowError(TimeoutError)
clock.tick(TIMEOUT)
await assert.rejects(promise, TimeoutError)
})
})
@@ -105,8 +107,8 @@ describe('rmdir()', () => {
})
const promise = testHandler.rmdir('dir')
jest.advanceTimersByTime(TIMEOUT)
await expect(promise).rejects.toThrowError(TimeoutError)
clock.tick(TIMEOUT)
await assert.rejects(promise, TimeoutError)
})
})
@@ -115,14 +117,14 @@ describe('encryption', () => {
beforeEach(async () => {
dir = await pFromCallback(cb => tmp.dir(cb))
})
afterAll(async () => {
after(async () => {
await rimraf(dir)
})
it('sync should NOT create metadata if missing (not encrypted)', async () => {
await Disposable.use(getSyncedHandler({ url: `file://${dir}` }), noop)
expect(await fs.readdir(dir)).toEqual([])
assert.deepEqual(await fs.readdir(dir), [])
})
it('sync should create metadata if missing (encrypted)', async () => {
@@ -131,12 +133,12 @@ describe('encryption', () => {
noop
)
expect(await fs.readdir(dir)).toEqual(['encryption.json', 'metadata.json'])
assert.deepEqual(await fs.readdir(dir), ['encryption.json', 'metadata.json'])
const encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
expect(encryption.algorithm).toEqual(DEFAULT_ENCRYPTION_ALGORITHM)
assert.equal(encryption.algorithm, DEFAULT_ENCRYPTION_ALGORITHM)
// encrypted , should not be parsable
expect(async () => JSON.parse(await fs.readFile(`${dir}/metadata.json`))).rejects.toThrowError()
assert.rejects(async () => JSON.parse(await fs.readFile(`${dir}/metadata.json`)))
})
it('sync should not modify existing metadata', async () => {
@@ -146,9 +148,9 @@ describe('encryption', () => {
await Disposable.use(await getSyncedHandler({ url: `file://${dir}` }), noop)
const encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
expect(encryption.algorithm).toEqual('none')
assert.equal(encryption.algorithm, 'none')
const metadata = JSON.parse(await fs.readFile(`${dir}/metadata.json`, 'utf-8'))
expect(metadata.random).toEqual('NOTSORANDOM')
assert.equal(metadata.random, 'NOTSORANDOM')
})
it('should modify metadata if empty', async () => {
@@ -160,11 +162,11 @@ describe('encryption', () => {
noop
)
let encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
expect(encryption.algorithm).toEqual(DEFAULT_ENCRYPTION_ALGORITHM)
assert.equal(encryption.algorithm, DEFAULT_ENCRYPTION_ALGORITHM)
await Disposable.use(getSyncedHandler({ url: `file://${dir}` }), noop)
encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
expect(encryption.algorithm).toEqual('none')
assert.equal(encryption.algorithm, 'none')
})
it(
@@ -178,9 +180,9 @@ describe('encryption', () => {
const handler = yield getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd91"` })
const encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
expect(encryption.algorithm).toEqual(DEFAULT_ENCRYPTION_ALGORITHM)
assert.equal(encryption.algorithm, DEFAULT_ENCRYPTION_ALGORITHM)
const metadata = JSON.parse(await handler.readFile(`./metadata.json`))
expect(metadata.random).toEqual('NOTSORANDOM')
assert.equal(metadata.random, 'NOTSORANDOM')
})
)
@@ -198,9 +200,9 @@ describe('encryption', () => {
// remote is now non empty : can't modify key anymore
await fs.writeFile(`${dir}/nonempty.json`, 'content')
await expect(
await assert.rejects(
Disposable.use(getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd10"` }), noop)
).rejects.toThrowError()
)
})
it('sync should fail when changing algorithm', async () => {
@@ -213,8 +215,8 @@ describe('encryption', () => {
// remote is now non empty : can't modify key anymore
await fs.writeFile(`${dir}/nonempty.json`, 'content')
await expect(
await assert.rejects(
Disposable.use(getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd91"` }), noop)
).rejects.toThrowError()
)
})
})

View File

@@ -1,4 +1,5 @@
/* eslint-env jest */
import { after, afterEach, before, beforeEach, describe, it } from 'test'
import { strict as assert } from 'assert'
import 'dotenv/config'
import { forOwn, random } from 'lodash'
@@ -53,11 +54,11 @@ handlers.forEach(url => {
})
}
beforeAll(async () => {
before(async () => {
handler = getHandler({ url }).addPrefix(`xo-fs-tests-${Date.now()}`)
await handler.sync()
})
afterAll(async () => {
after(async () => {
await handler.forget()
handler = undefined
})
@@ -72,67 +73,63 @@ handlers.forEach(url => {
describe('#type', () => {
it('returns the type of the remote', () => {
expect(typeof handler.type).toBe('string')
assert.equal(typeof handler.type, 'string')
})
})
describe('#getInfo()', () => {
let info
beforeAll(async () => {
before(async () => {
info = await handler.getInfo()
})
it('should return an object with info', async () => {
expect(typeof info).toBe('object')
assert.equal(typeof info, 'object')
})
it('should return correct type of attribute', async () => {
if (info.size !== undefined) {
expect(typeof info.size).toBe('number')
assert.equal(typeof info.size, 'number')
}
if (info.used !== undefined) {
expect(typeof info.used).toBe('number')
assert.equal(typeof info.used, 'number')
}
})
})
describe('#getSize()', () => {
beforeEach(() => handler.outputFile('file', TEST_DATA))
before(() => handler.outputFile('file', TEST_DATA))
testWithFileDescriptor('file', 'r', async () => {
expect(await handler.getSize('file')).toEqual(TEST_DATA_LEN)
assert.equal(await handler.getSize('file'), TEST_DATA_LEN)
})
})
describe('#list()', () => {
it(`should list the content of folder`, async () => {
await handler.outputFile('file', TEST_DATA)
await expect(await handler.list('.')).toEqual(['file'])
assert.deepEqual(await handler.list('.'), ['file'])
})
it('can prepend the directory to entries', async () => {
await handler.outputFile('dir/file', '')
expect(await handler.list('dir', { prependDir: true })).toEqual(['/dir/file'])
})
it('can prepend the directory to entries', async () => {
await handler.outputFile('dir/file', '')
expect(await handler.list('dir', { prependDir: true })).toEqual(['/dir/file'])
assert.deepEqual(await handler.list('dir', { prependDir: true }), ['/dir/file'])
})
it('throws ENOENT if no such directory', async () => {
expect((await rejectionOf(handler.list('dir'))).code).toBe('ENOENT')
await handler.rmtree('dir')
assert.equal((await rejectionOf(handler.list('dir'))).code, 'ENOENT')
})
it('can returns empty for missing directory', async () => {
expect(await handler.list('dir', { ignoreMissing: true })).toEqual([])
assert.deepEqual(await handler.list('dir', { ignoreMissing: true }), [])
})
})
describe('#mkdir()', () => {
it('creates a directory', async () => {
await handler.mkdir('dir')
await expect(await handler.list('.')).toEqual(['dir'])
assert.deepEqual(await handler.list('.'), ['dir'])
})
it('does not throw on existing directory', async () => {
@@ -143,15 +140,15 @@ handlers.forEach(url => {
it('throws ENOTDIR on existing file', async () => {
await handler.outputFile('file', '')
const error = await rejectionOf(handler.mkdir('file'))
expect(error.code).toBe('ENOTDIR')
assert.equal(error.code, 'ENOTDIR')
})
})
describe('#mktree()', () => {
it('creates a tree of directories', async () => {
await handler.mktree('dir/dir')
await expect(await handler.list('.')).toEqual(['dir'])
await expect(await handler.list('dir')).toEqual(['dir'])
assert.deepEqual(await handler.list('.'), ['dir'])
assert.deepEqual(await handler.list('dir'), ['dir'])
})
it('does not throw on existing directory', async () => {
@@ -162,26 +159,27 @@ handlers.forEach(url => {
it('throws ENOTDIR on existing file', async () => {
await handler.outputFile('dir/file', '')
const error = await rejectionOf(handler.mktree('dir/file'))
expect(error.code).toBe('ENOTDIR')
assert.equal(error.code, 'ENOTDIR')
})
it('throws ENOTDIR on existing file in path', async () => {
await handler.outputFile('file', '')
const error = await rejectionOf(handler.mktree('file/dir'))
expect(error.code).toBe('ENOTDIR')
assert.equal(error.code, 'ENOTDIR')
})
})
describe('#outputFile()', () => {
it('writes data to a file', async () => {
await handler.outputFile('file', TEST_DATA)
expect(await handler.readFile('file')).toEqual(TEST_DATA)
assert.deepEqual(await handler.readFile('file'), TEST_DATA)
})
it('throws on existing files', async () => {
await handler.unlink('file')
await handler.outputFile('file', '')
const error = await rejectionOf(handler.outputFile('file', ''))
expect(error.code).toBe('EEXIST')
assert.equal(error.code, 'EEXIST')
})
it("shouldn't timeout in case of the respect of the parallel execution restriction", async () => {
@@ -192,7 +190,7 @@ handlers.forEach(url => {
})
describe('#read()', () => {
beforeEach(() => handler.outputFile('file', TEST_DATA))
before(() => handler.outputFile('file', TEST_DATA))
const start = random(TEST_DATA_LEN)
const size = random(TEST_DATA_LEN)
@@ -200,8 +198,8 @@ handlers.forEach(url => {
testWithFileDescriptor('file', 'r', async ({ file }) => {
const buffer = Buffer.alloc(size)
const result = await handler.read(file, buffer, start)
expect(result.buffer).toBe(buffer)
expect(result).toEqual({
assert.deepEqual(result.buffer, buffer)
assert.deepEqual(result, {
buffer,
bytesRead: Math.min(size, TEST_DATA_LEN - start),
})
@@ -211,12 +209,13 @@ handlers.forEach(url => {
describe('#readFile', () => {
it('returns a buffer containing the contents of the file', async () => {
await handler.outputFile('file', TEST_DATA)
expect(await handler.readFile('file')).toEqual(TEST_DATA)
assert.deepEqual(await handler.readFile('file'), TEST_DATA)
})
it('throws on missing file', async () => {
await handler.unlink('file')
const error = await rejectionOf(handler.readFile('file'))
expect(error.code).toBe('ENOENT')
assert.equal(error.code, 'ENOENT')
})
})
@@ -225,19 +224,19 @@ handlers.forEach(url => {
await handler.outputFile('file', TEST_DATA)
await handler.rename('file', `file2`)
expect(await handler.list('.')).toEqual(['file2'])
expect(await handler.readFile(`file2`)).toEqual(TEST_DATA)
assert.deepEqual(await handler.list('.'), ['file2'])
assert.deepEqual(await handler.readFile(`file2`), TEST_DATA)
})
it(`should rename the file and create dest directory`, async () => {
await handler.outputFile('file', TEST_DATA)
await handler.rename('file', `sub/file2`)
expect(await handler.list('sub')).toEqual(['file2'])
expect(await handler.readFile(`sub/file2`)).toEqual(TEST_DATA)
assert.deepEqual(await handler.list('sub'), ['file2'])
assert.deepEqual(await handler.readFile(`sub/file2`), TEST_DATA)
})
it(`should fail with enoent if source file is missing`, async () => {
const error = await rejectionOf(handler.rename('file', `sub/file2`))
expect(error.code).toBe('ENOENT')
assert.equal(error.code, 'ENOENT')
})
})
@@ -245,14 +244,15 @@ handlers.forEach(url => {
it('should remove an empty directory', async () => {
await handler.mkdir('dir')
await handler.rmdir('dir')
expect(await handler.list('.')).toEqual([])
assert.deepEqual(await handler.list('.'), [])
})
it(`should throw on non-empty directory`, async () => {
await handler.outputFile('dir/file', '')
const error = await rejectionOf(handler.rmdir('.'))
await expect(error.code).toEqual('ENOTEMPTY')
assert.equal(error.code, 'ENOTEMPTY')
await handler.unlink('dir/file')
})
it('does not throw on missing directory', async () => {
@@ -265,7 +265,7 @@ handlers.forEach(url => {
await handler.outputFile('dir/file', '')
await handler.rmtree('dir')
expect(await handler.list('.')).toEqual([])
assert.deepEqual(await handler.list('.'), [])
})
})
@@ -273,9 +273,9 @@ handlers.forEach(url => {
it('tests the remote appears to be working', async () => {
const answer = await handler.test()
expect(answer.success).toBe(true)
expect(typeof answer.writeRate).toBe('number')
expect(typeof answer.readRate).toBe('number')
assert.equal(answer.success, true)
assert.equal(typeof answer.writeRate, 'number')
assert.equal(typeof answer.readRate, 'number')
})
})
@@ -284,7 +284,7 @@ handlers.forEach(url => {
await handler.outputFile('file', TEST_DATA)
await handler.unlink('file')
await expect(await handler.list('.')).toEqual([])
assert.deepEqual(await handler.list('.'), [])
})
it('does not throw on missing file', async () => {
@@ -294,6 +294,7 @@ handlers.forEach(url => {
describe('#write()', () => {
beforeEach(() => handler.outputFile('file', TEST_DATA))
afterEach(() => handler.unlink('file'))
const PATCH_DATA_LEN = Math.ceil(TEST_DATA_LEN / 2)
const PATCH_DATA = unsecureRandomBytes(PATCH_DATA_LEN)
@@ -322,7 +323,7 @@ handlers.forEach(url => {
describe(title, () => {
testWithFileDescriptor('file', 'r+', async ({ file }) => {
await handler.write(file, PATCH_DATA, offset)
await expect(await handler.readFile('file')).toEqual(expected)
assert.deepEqual(await handler.readFile('file'), expected)
})
})
}
@@ -330,6 +331,7 @@ handlers.forEach(url => {
})
describe('#truncate()', () => {
afterEach(() => handler.unlink('file'))
forOwn(
{
'shrinks file': (() => {
@@ -348,7 +350,7 @@ handlers.forEach(url => {
it(title, async () => {
await handler.outputFile('file', TEST_DATA)
await handler.truncate('file', length)
await expect(await handler.readFile('file')).toEqual(expected)
assert.deepEqual(await handler.readFile('file'), expected)
})
}
)

View File

@@ -18,6 +18,7 @@
"@novnc/novnc": "^1.3.0",
"@types/d3-time-format": "^4.0.0",
"@types/lodash-es": "^4.17.6",
"@types/marked": "^4.0.8",
"@vueuse/core": "^9.5.0",
"@vueuse/math": "^9.5.0",
"complex-matcher": "^0.7.0",
@@ -25,13 +26,14 @@
"decorator-synchronized": "^0.6.0",
"echarts": "^5.3.3",
"highlight.js": "^11.6.0",
"human-format": "^1.0.0",
"human-format": "^1.1.0",
"iterable-backoff": "^0.1.0",
"json-rpc-2.0": "^1.3.0",
"json5": "^2.2.1",
"limit-concurrency-decorator": "^0.5.0",
"lodash-es": "^4.17.21",
"make-error": "^1.3.6",
"markdown-it": "^13.0.1",
"marked": "^4.2.12",
"pinia": "^2.0.14",
"placement.js": "^1.0.0-beta.5",
"vue": "^3.2.37",

View File

@@ -1,25 +1,5 @@
<template>
<UiModal
v-if="isSslModalOpen"
:icon="faServer"
color="error"
@close="clearUnreachableHostsUrls"
>
<template #title>{{ $t("unreachable-hosts") }}</template>
<template #subtitle>{{ $t("following-hosts-unreachable") }}</template>
<p>{{ $t("allow-self-signed-ssl") }}</p>
<ul>
<li v-for="url in unreachableHostsUrls" :key="url.hostname">
<a :href="url.href" rel="noopener" target="_blank">{{ url.href }}</a>
</li>
</ul>
<template #buttons>
<UiButton color="success" @click="reload">
{{ $t("unreachable-hosts-reload-page") }}
</UiButton>
<UiButton @click="clearUnreachableHostsUrls">{{ $t("cancel") }}</UiButton>
</template>
</UiModal>
<UnreachableHostsModal />
<div v-if="!$route.meta.hasStoryNav && !xenApiStore.isConnected">
<AppLogin />
</div>
@@ -41,21 +21,14 @@ import AppHeader from "@/components/AppHeader.vue";
import AppLogin from "@/components/AppLogin.vue";
import AppNavigation from "@/components/AppNavigation.vue";
import AppTooltips from "@/components/AppTooltips.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiModal from "@/components/ui/UiModal.vue";
import UnreachableHostsModal from "@/components/UnreachableHostsModal.vue";
import { useChartTheme } from "@/composables/chart-theme.composable";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store";
import { useUiStore } from "@/stores/ui.store";
import { useXenApiStore } from "@/stores/xen-api.store";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import { useActiveElement, useMagicKeys, whenever } from "@vueuse/core";
import { logicAnd } from "@vueuse/math";
import { difference } from "lodash";
import { computed, ref, watch, watchEffect } from "vue";
import { useRoute } from "vue-router";
const unreachableHostsUrls = ref<URL[]>([]);
const clearUnreachableHostsUrls = () => (unreachableHostsUrls.value = []);
import { computed } from "vue";
let link = document.querySelector(
"link[rel~='icon']"
@@ -70,7 +43,7 @@ link.href = favicon;
document.title = "XO Lite";
const xenApiStore = useXenApiStore();
const hostStore = useHostStore();
const { pool } = usePoolStore().subscribe();
useChartTheme();
const uiStore = useUiStore();
@@ -92,34 +65,14 @@ if (import.meta.env.DEV) {
);
}
const route = useRoute();
watchEffect(() => {
if (route.meta.hasStoryNav) {
return;
}
if (xenApiStore.isConnected) {
xenApiStore.init();
}
});
watch(
() => hostStore.allRecords,
(hosts, previousHosts) => {
difference(hosts, previousHosts).forEach((host) => {
const url = new URL("http://localhost");
url.protocol = window.location.protocol;
url.hostname = host.address;
fetch(url, { mode: "no-cors" }).catch(() =>
unreachableHostsUrls.value.push(url)
);
});
whenever(
() => pool.value?.$ref,
async (poolRef) => {
const xenApi = xenApiStore.getXapi();
await xenApi.injectWatchEvent(poolRef);
await xenApi.startWatch();
}
);
const isSslModalOpen = computed(() => unreachableHostsUrls.value.length > 0);
const reload = () => window.location.reload();
</script>
<style lang="postcss">

View File

@@ -38,6 +38,11 @@ code * {
color: var(--color-extra-blue-d20);
}
.link:active {
.link:active,
.link.router-link-active {
color: var(--color-extra-blue-d40);
}
.link.router-link-active {
text-decoration: underline;
}

View File

@@ -12,13 +12,23 @@ html {
font-family: Poppins, sans-serif;
}
body, h1, h2, h3, h4, h5, h6, p, ol, ul {
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
ol,
ul {
margin: 0;
padding: 0;
font-weight: normal;
}
ol, ul {
ol,
ul {
list-style: none;
}

View File

@@ -1,75 +1,85 @@
:root {
--color-blue-scale-000: #000000;
--color-blue-scale-100: #1A1B38;
--color-blue-scale-200: #595A6F;
--color-blue-scale-300: #9899A5;
--color-blue-scale-400: #E5E5E7;
--color-blue-scale-500: #FFFFFF;
--color-blue-scale-100: #1a1b38;
--color-blue-scale-200: #595a6f;
--color-blue-scale-300: #9899a5;
--color-blue-scale-400: #e5e5e7;
--color-blue-scale-500: #ffffff;
--color-extra-blue-l60: #D1CEFB;
--color-extra-blue-l40: #BBB5F9;
--color-extra-blue-l20: #A39DF8;
--color-extra-blue-base: #8F84FF;
--color-extra-blue-d20: #716AC6;
--color-extra-blue-d40: #554F94;
--color-extra-blue-l60: #d1cefb;
--color-extra-blue-l40: #bbb5f9;
--color-extra-blue-l20: #a39df8;
--color-extra-blue-base: #8f84ff;
--color-extra-blue-d20: #716ac6;
--color-extra-blue-d40: #554f94;
--color-extra-blue-d60: #383563;
--color-green-infra-l60: #B5DBCA;
--color-green-infra-l40: #91C9B0;
--color-green-infra-l20: #70B795;
--color-green-infra-base: #55A57B;
--color-green-infra-l60: #b5dbca;
--color-green-infra-l40: #91c9b0;
--color-green-infra-l20: #70b795;
--color-green-infra-base: #55a57b;
--color-green-infra-d20: #438463;
--color-green-infra-d40: #32634A;
--color-green-infra-d40: #32634a;
--color-green-infra-d60: #214231;
--color-orange-world-l60: #F2CDA8;
--color-orange-world-l40: #EBB57D;
--color-orange-world-l20: #E59D56;
--color-orange-world-base: #EF7F18;
--color-orange-world-d20: #BF6612;
--color-orange-world-d40: #864F1F;
--color-orange-world-d60: #5A3514;
--color-orange-world-l60: #f2cda8;
--color-orange-world-l40: #ebb57d;
--color-orange-world-l20: #e59d56;
--color-orange-world-base: #ef7f18;
--color-orange-world-d20: #bf6612;
--color-orange-world-d40: #864f1f;
--color-orange-world-d60: #5a3514;
--color-red-vates-l60: #DDA5A7;
--color-red-vates-l40: #CE787C;
--color-red-vates-l20: #BF4F51;
--color-red-vates-base: #BE1621;
--color-red-vates-d20: #8E2221;
--color-red-vates-d40: #6A1919;
--color-red-vates-l60: #dda5a7;
--color-red-vates-l40: #ce787c;
--color-red-vates-l20: #bf4f51;
--color-red-vates-base: #be1621;
--color-red-vates-d20: #8e2221;
--color-red-vates-d40: #6a1919;
--color-red-vates-d60: #471010;
--color-grayscale-200: #585757;
--background-color-primary: #FFFFFF;
--background-color-secondary: #F6F6F7;
--background-color-extra-blue: #F4F3FE;
--background-color-green-infra: #ECF5F2;
--background-color-orange-world: #FBF2E9;
--background-color-red-vates: #F5E8E9;
--background-color-primary: #ffffff;
--background-color-secondary: #f6f6f7;
--background-color-extra-blue: #f4f3fe;
--background-color-green-infra: #ecf5f2;
--background-color-orange-world: #fbf2e9;
--background-color-red-vates: #f5e8e9;
--shadow-100: 0 0.1rem 0.1rem rgba(20, 20, 30, 0.06);
--shadow-200: 0 0.1rem 0.3rem rgba(20, 20, 30, 0.1), 0 0.2rem 0.1rem rgba(20, 20, 30, 0.06), 0 0.1rem 0.1rem rgba(20, 20, 30, 0.08);
--shadow-300: 0 0.3rem 0.5rem rgba(20, 20, 30, 0.1), 0 0.1rem 1.8rem rgba(20, 20, 30, 0.06), 0 0.6rem 1.0rem rgba(20, 20, 30, 0.08);
--shadow-400: 0 1.1rem 1.5rem rgba(20, 20, 30, 0.1), 0 0.9rem 4.6rem rgba(20, 20, 30, 0.06), 0 2.4rem 3.8rem rgba(20, 20, 30, 0.04);
--shadow-200: 0 0.1rem 0.3rem rgba(20, 20, 30, 0.1),
0 0.2rem 0.1rem rgba(20, 20, 30, 0.06),
0 0.1rem 0.1rem rgba(20, 20, 30, 0.08);
--shadow-300: 0 0.3rem 0.5rem rgba(20, 20, 30, 0.1),
0 0.1rem 1.8rem rgba(20, 20, 30, 0.06), 0 0.6rem 1rem rgba(20, 20, 30, 0.08);
--shadow-400: 0 1.1rem 1.5rem rgba(20, 20, 30, 0.1),
0 0.9rem 4.6rem rgba(20, 20, 30, 0.06),
0 2.4rem 3.8rem rgba(20, 20, 30, 0.04);
}
:root.dark {
--color-blue-scale-000: #FFFFFF;
--color-blue-scale-100: #E5E5E7;
--color-blue-scale-200: #9899A5;
--color-blue-scale-300: #595A6F;
--color-blue-scale-400: #1A1B38;
--color-blue-scale-000: #ffffff;
--color-blue-scale-100: #e5e5e7;
--color-blue-scale-200: #9899a5;
--color-blue-scale-300: #595a6f;
--color-blue-scale-400: #1a1b38;
--color-blue-scale-500: #000000;
--background-color-primary: #14141D;
--background-color-secondary: #17182A;
--background-color-extra-blue: #35335D;
--background-color-green-infra: #243B3D;
--background-color-primary: #14141d;
--background-color-secondary: #17182a;
--background-color-extra-blue: #35335d;
--background-color-green-infra: #243b3d;
--background-color-orange-world: #493328;
--background-color-red-vates: #3C1A28;
--background-color-red-vates: #3c1a28;
--shadow-100: 0 0.1rem 0.1rem rgba(20, 20, 30, 0.12);
--shadow-200: 0 0.1rem 0.3rem rgba(20, 20, 30, 0.2), 0 0.2rem 0.1rem rgba(20, 20, 30, 0.12), 0 0.1rem 0.1rem rgba(20, 20, 30, 0.16);
--shadow-300: 0 0.3rem 0.5rem rgba(20, 20, 30, 0.2), 0 0.1rem 1.8rem rgba(20, 20, 30, 0.12), 0 0.6rem 1.0rem rgba(20, 20, 30, 0.16);
--shadow-400: 0 1.1rem 1.5rem rgba(20, 20, 30, 0.2), 0 0.9rem 4.6rem rgba(20, 20, 30, 0.12), 0 2.4rem 3.8rem rgba(20, 20, 30, 0.08);
--shadow-200: 0 0.1rem 0.3rem rgba(20, 20, 30, 0.2),
0 0.2rem 0.1rem rgba(20, 20, 30, 0.12),
0 0.1rem 0.1rem rgba(20, 20, 30, 0.16);
--shadow-300: 0 0.3rem 0.5rem rgba(20, 20, 30, 0.2),
0 0.1rem 1.8rem rgba(20, 20, 30, 0.12), 0 0.6rem 1rem rgba(20, 20, 30, 0.16);
--shadow-400: 0 1.1rem 1.5rem rgba(20, 20, 30, 0.2),
0 0.9rem 4.6rem rgba(20, 20, 30, 0.12),
0 2.4rem 3.8rem rgba(20, 20, 30, 0.08);
}

View File

@@ -3,10 +3,10 @@
</template>
<script lang="ts" setup>
import { type Ref, computed, ref } from "vue";
import markdown from "@/libs/markdown";
import { useEventListener } from "@vueuse/core";
import "highlight.js/styles/github-dark.css";
import { markdown } from "@/libs/markdown";
import { computed, type Ref, ref } from "vue";
const rootElement = ref() as Ref<HTMLElement>;
@@ -14,7 +14,7 @@ const props = defineProps<{
content: string;
}>();
const html = computed(() => markdown.render(props.content ?? ""));
const html = computed(() => markdown.parse(props.content ?? ""));
useEventListener(
rootElement,
@@ -96,6 +96,7 @@ useEventListener(
position: absolute;
z-index: 1;
right: 1rem;
top: 0.4rem;
cursor: pointer;
color: white;
border: none;

View File

@@ -1,15 +1,15 @@
<template>
<div v-if="!isDisabled" ref="tooltipElement" class="app-tooltip">
<span class="triangle" />
<span class="label">{{ content }}</span>
<span class="label">{{ options.content }}</span>
</div>
</template>
<script lang="ts" setup>
import { isEmpty, isFunction, isString } from "lodash-es";
import type { TooltipOptions } from "@/stores/tooltip.store";
import { isString } from "lodash-es";
import place from "placement.js";
import { computed, ref, watchEffect } from "vue";
import type { TooltipOptions } from "@/stores/tooltip.store";
const props = defineProps<{
target: HTMLElement;
@@ -18,29 +18,13 @@ const props = defineProps<{
const tooltipElement = ref<HTMLElement>();
const content = computed(() =>
isString(props.options) ? props.options : props.options.content
const isDisabled = computed(() =>
isString(props.options.content)
? props.options.content.trim() === ""
: props.options.content === false
);
const isDisabled = computed(() => {
if (isEmpty(content.value)) {
return true;
}
if (isString(props.options)) {
return false;
}
if (isFunction(props.options.disabled)) {
return props.options.disabled(props.target);
}
return props.options.disabled ?? false;
});
const placement = computed(() =>
isString(props.options) ? "top" : props.options.placement ?? "top"
);
const placement = computed(() => props.options.placement ?? "top");
watchEffect(() => {
if (tooltipElement.value) {

View File

@@ -14,7 +14,12 @@
</UiActionButton>
</UiFilterGroup>
<UiModal v-if="isOpen" :icon="faFilter" @submit.prevent="handleSubmit">
<UiModal
v-if="isOpen"
:icon="faFilter"
@submit.prevent="handleSubmit"
@close="handleCancel"
>
<div class="rows">
<CollectionFilterRow
v-for="(newFilter, index) in newFilters"

View File

@@ -17,7 +17,12 @@
</UiActionButton>
</UiFilterGroup>
<UiModal v-if="isOpen" :icon="faSort" @submit.prevent="handleSubmit">
<UiModal
v-if="isOpen"
:icon="faSort"
@submit.prevent="handleSubmit"
@close="handleCancel"
>
<div class="form-widgets">
<FormWidget :label="$t('sort-by')">
<select v-model="newSortProperty">

View File

@@ -18,24 +18,27 @@
/>
</div>
<UiTable>
<template #header>
<td v-if="isSelectable">
<input v-model="areAllSelected" type="checkbox" />
</td>
<slot name="header" />
</template>
<tr v-for="item in filteredAndSortedCollection" :key="item[idProperty]">
<td v-if="isSelectable">
<input
v-model="selected"
:value="item[props.idProperty]"
type="checkbox"
/>
</td>
<slot :item="item" name="row" />
</tr>
<UiTable vertical-border>
<thead>
<tr>
<td v-if="isSelectable">
<input v-model="areAllSelected" type="checkbox" />
</td>
<slot name="head-row" />
</tr>
</thead>
<tbody>
<tr v-for="item in filteredAndSortedCollection" :key="item[idProperty]">
<td v-if="isSelectable">
<input
v-model="selected"
:value="item[props.idProperty]"
type="checkbox"
/>
</td>
<slot :item="item" name="body-row" />
</tr>
</tbody>
</UiTable>
</template>

View File

@@ -1,33 +1,31 @@
<template>
<div class="wrapper-spinner" v-if="store.isLoading">
<div v-if="!isReady" class="wrapper-spinner">
<UiSpinner class="spinner" />
</div>
<ObjectNotFoundView :id="id" v-else-if="isRecordNotFound" />
<ObjectNotFoundView v-else-if="isRecordNotFound" :id="id" />
<slot v-else />
</template>
<script lang="ts" setup>
import ObjectNotFoundView from "@/views/ObjectNotFoundView.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import { useHostStore } from "@/stores/host.store";
import { useVmStore } from "@/stores/vm.store";
import ObjectNotFoundView from "@/views/ObjectNotFoundView.vue";
import { computed } from "vue";
import { useRouter } from "vue-router";
const storeByType = {
vm: useVmStore,
host: useHostStore,
};
const props = defineProps<{
isReady: boolean;
uuidChecker: (uuid: string) => boolean;
id?: string;
}>();
const props = defineProps<{ objectType: "vm" | "host"; id?: string }>();
const store = storeByType[props.objectType]();
const { currentRoute } = useRouter();
const id = computed(
() => props.id ?? (currentRoute.value.params.uuid as string)
);
const isRecordNotFound = computed(
() => store.isReady && !store.hasRecordByUuid(id.value)
() => props.isReady && !props.uuidChecker(id.value)
);
</script>

View File

@@ -3,9 +3,16 @@
</template>
<script lang="ts" setup>
import { onBeforeUnmount, ref, watchEffect } from "vue";
import { fibonacci } from "iterable-backoff";
import { computed, onBeforeUnmount, ref, watch, watchEffect } from "vue";
import VncClient from "@novnc/novnc/core/rfb";
import { useXenApiStore } from "@/stores/xen-api.store";
import { promiseTimeout } from "@vueuse/shared";
const N_TOTAL_TRIES = 8;
const FIBONACCI_MS_ARRAY: number[] = Array.from(
fibonacci().toMs().take(N_TOTAL_TRIES)
);
const props = defineProps<{
location: string;
@@ -14,37 +21,84 @@ const props = defineProps<{
const vmConsoleContainer = ref<HTMLDivElement>();
const xenApiStore = useXenApiStore();
let vncClient: VncClient | undefined;
const url = computed(() => {
if (xenApiStore.currentSessionId == null) {
return;
}
const _url = new URL(props.location);
_url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
_url.searchParams.set("session_id", xenApiStore.currentSessionId);
return _url;
});
const clearVncClient = () => {
if (vncClient !== undefined) {
if (vncClient._rfbConnectionState !== "disconnected") {
vncClient.disconnect();
let vncClient: VncClient | undefined;
let nConnectionAttempts = 0;
const handleDisconnectionEvent = () => {
clearVncClient();
if (props.isConsoleAvailable) {
nConnectionAttempts++;
if (nConnectionAttempts > N_TOTAL_TRIES) {
console.error(
"The number of reconnection attempts has been exceeded for:",
props.location
);
return;
}
vncClient = undefined;
console.error(
`Connection lost for the remote console: ${
props.location
}. New attempt in ${FIBONACCI_MS_ARRAY[nConnectionAttempts - 1]}ms`
);
createVncConnection();
}
};
const handleConnectionEvent = () => (nConnectionAttempts = 0);
const clearVncClient = () => {
if (vncClient === undefined) {
return;
}
vncClient.removeEventListener("disconnect", handleDisconnectionEvent);
vncClient.removeEventListener("connect", handleConnectionEvent);
if (vncClient._rfbConnectionState !== "disconnected") {
vncClient.disconnect();
}
vncClient = undefined;
};
const createVncConnection = async () => {
if (nConnectionAttempts !== 0) {
await promiseTimeout(FIBONACCI_MS_ARRAY[nConnectionAttempts - 1]);
}
vncClient = new VncClient(vmConsoleContainer.value!, url.value!.toString(), {
wsProtocols: ["binary"],
});
vncClient.scaleViewport = true;
vncClient.addEventListener("disconnect", handleDisconnectionEvent);
vncClient.addEventListener("connect", handleConnectionEvent);
};
watch(url, clearVncClient);
watchEffect(() => {
if (
!vmConsoleContainer.value ||
!xenApiStore.currentSessionId ||
url.value === undefined ||
vmConsoleContainer.value === undefined ||
!props.isConsoleAvailable
) {
return;
}
clearVncClient();
const url = new URL(props.location);
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
url.searchParams.set("session_id", xenApiStore.currentSessionId);
vncClient = new VncClient(vmConsoleContainer.value, url.toString(), {
wsProtocols: ["binary"],
});
vncClient.scaleViewport = true;
nConnectionAttempts = 0;
createVncConnection();
});
onBeforeUnmount(() => {

View File

@@ -0,0 +1,59 @@
<template>
<UiModal
v-if="isSslModalOpen"
:icon="faServer"
color="error"
@close="clearUnreachableHostsUrls"
>
<template #title>{{ $t("unreachable-hosts") }}</template>
<div class="description">
<p>{{ $t("following-hosts-unreachable") }}</p>
<p>{{ $t("allow-self-signed-ssl") }}</p>
<ul>
<li v-for="url in unreachableHostsUrls" :key="url">
<a :href="url" class="link" rel="noopener" target="_blank">{{
url
}}</a>
</li>
</ul>
</div>
<template #buttons>
<UiButton color="success" @click="reload">
{{ $t("unreachable-hosts-reload-page") }}
</UiButton>
<UiButton @click="clearUnreachableHostsUrls">{{ $t("cancel") }}</UiButton>
</template>
</UiModal>
</template>
<script lang="ts" setup>
import { faServer } from "@fortawesome/free-solid-svg-icons";
import UiModal from "@/components/ui/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { computed, ref, watch } from "vue";
import { difference } from "lodash";
import { useHostStore } from "@/stores/host.store";
const { records: hosts } = useHostStore().subscribe();
const unreachableHostsUrls = ref<Set<string>>(new Set());
const clearUnreachableHostsUrls = () => unreachableHostsUrls.value.clear();
const isSslModalOpen = computed(() => unreachableHostsUrls.value.size > 0);
const reload = () => window.location.reload();
watch(hosts, (nextHosts, previousHosts) => {
difference(nextHosts, previousHosts).forEach((host) => {
const url = new URL("http://localhost");
url.protocol = window.location.protocol;
url.hostname = host.address;
fetch(url, { mode: "no-cors" }).catch(() =>
unreachableHostsUrls.value.add(url.toString())
);
});
});
</script>
<style lang="postcss" scoped>
.description p {
margin: 1rem 0;
}
</style>

View File

@@ -19,6 +19,7 @@
class="preset-tab"
@click="open"
>
<UiIcon :icon="faSliders" />
Presets
</UiTab>
</template>
@@ -105,6 +106,7 @@ import UiButton from "@/components/ui/UiButton.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiCounter from "@/components/ui/UiCounter.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiTab from "@/components/ui/UiTab.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
import {
@@ -116,6 +118,7 @@ import {
ModelParam,
type Param,
} from "@/libs/story/story-param";
import { faSliders } from "@fortawesome/free-solid-svg-icons";
import "highlight.js/styles/github-dark.css";
import { uniqueId, upperFirst } from "lodash-es";
import { computed, reactive, ref, watch, watchEffect } from "vue";
@@ -174,8 +177,8 @@ if (propParams.value.length !== 0) {
selectedTab.value = TAB.SETTINGS;
}
const propValues = reactive<Record<string, any>>({});
const settingValues = reactive<Record<string, any>>({});
const propValues = ref<Record<string, any>>({});
const settingValues = ref<Record<string, any>>({});
const eventsLog = ref<
{ id: string; name: string; args: { name: string; value: any }[] }[]
>([]);
@@ -183,13 +186,13 @@ const unreadEventsCount = ref(0);
const resetProps = () => {
propParams.value.forEach((param) => {
propValues[param.name] = param.getPresetValue();
propValues.value[param.name] = param.getPresetValue();
});
};
const resetSettings = () => {
settingParams.value.forEach((param) => {
settingValues[param.name] = param.getPresetValue();
settingValues.value[param.name] = param.getPresetValue();
});
};
@@ -234,13 +237,13 @@ const slotProperties = computed(() => {
const properties: Record<string, any> = {};
propParams.value.forEach(({ name }) => {
properties[name] = propValues[name];
properties[name] = propValues.value[name];
});
eventParams.value.forEach((eventParam) => {
properties[`on${upperFirst(eventParam.name)}`] = (...args: any[]) => {
if (eventParam.isVModel()) {
propValues[eventParam.rawName] = args[0];
propValues.value[eventParam.rawName] = args[0];
}
const logArgs = Object.keys(eventParam.getArguments()).map(
(argName, index) => ({
@@ -260,7 +263,7 @@ const slotSettings = computed(() => {
const result: Record<string, any> = {};
settingParams.value.forEach(({ name }) => {
result[name] = settingValues[name];
result[name] = settingValues.value[name];
});
return result;
@@ -284,13 +287,13 @@ const applyPreset = (preset: {
}) => {
if (preset.props !== undefined) {
Object.entries(preset.props).forEach(([name, value]) => {
propValues[name] = value;
propValues.value[name] = value;
});
}
if (preset.settings !== undefined) {
Object.entries(preset.settings).forEach(([name, value]) => {
settingValues[name] = value;
settingValues.value[name] = value;
});
}
};

View File

@@ -2,7 +2,7 @@
<StoryParamsTable>
<thead>
<tr>
<th>Prop</th>
<th>Setting</th>
<th><!--Widget--></th>
<th>Help</th>
</tr>

View File

@@ -1,12 +1,5 @@
<template>
<li
v-if="host"
class="infra-host-item"
v-tooltip="{
content: host.name_label,
disabled: isTooltipDisabled,
}"
>
<li v-if="host !== undefined" class="infra-host-item">
<InfraItemLabel
:active="isCurrentHost"
:icon="faServer"
@@ -15,10 +8,10 @@
{{ host.name_label || "(Host)" }}
<template #actions>
<InfraAction
:icon="faStar"
class="master-icon"
v-if="isPoolMaster"
v-tooltip="'Master'"
:icon="faStar"
class="master-icon"
/>
<InfraAction
:icon="isExpanded ? faAngleDown : faAngleUp"
@@ -32,8 +25,13 @@
</template>
<script lang="ts" setup>
import { computed } from "vue";
import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import InfraVmList from "@/components/infra/InfraVmList.vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store";
import { useUiStore } from "@/stores/ui.store";
import {
faAngleDown,
faAngleUp,
@@ -41,25 +39,18 @@ import {
faStar,
} from "@fortawesome/free-solid-svg-icons";
import { useToggle } from "@vueuse/core";
import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import InfraVmList from "@/components/infra/InfraVmList.vue";
import { hasEllipsis } from "@/libs/utils";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store";
import { useUiStore } from "@/stores/ui.store";
import { computed } from "vue";
const props = defineProps<{
hostOpaqueRef: string;
}>();
const hostStore = useHostStore();
const host = computed(() => hostStore.getRecord(props.hostOpaqueRef));
const { getByOpaqueRef } = useHostStore().subscribe();
const host = computed(() => getByOpaqueRef(props.hostOpaqueRef));
const poolStore = usePoolStore();
const isPoolMaster = computed(
() => poolStore.pool?.master === props.hostOpaqueRef
);
const { pool } = usePoolStore().subscribe();
const isPoolMaster = computed(() => pool.value?.master === props.hostOpaqueRef);
const uiStore = useUiStore();
@@ -67,17 +58,16 @@ const isCurrentHost = computed(
() => props.hostOpaqueRef === uiStore.currentHostOpaqueRef
);
const [isExpanded, toggle] = useToggle(true);
const isTooltipDisabled = (target: HTMLElement) =>
!hasEllipsis(target.querySelector(".text"));
</script>
<style lang="postcss" scoped>
.infra-host-item:deep(.link) {
.infra-host-item:deep(.link),
.infra-host-item:deep(.link-placeholder) {
padding-left: 3rem;
}
.infra-vm-list:deep(.link) {
.infra-vm-list:deep(.link),
.infra-vm-list:deep(.link-placeholder) {
padding-left: 4.5rem;
}

View File

@@ -1,26 +1,24 @@
<template>
<ul class="infra-host-list">
<li v-if="isLoading">{{ $t("loading-hosts") }}</li>
<li v-else-if="hasError" class="text-error">
<li v-if="hasError" class="text-error">
{{ $t("error-no-data") }}
</li>
<li v-else-if="!isReady">{{ $t("loading-hosts") }}</li>
<template v-else>
<InfraHostItem
v-for="opaqueRef in opaqueRefs"
:key="opaqueRef"
:host-opaque-ref="opaqueRef"
v-for="host in hosts"
:key="host.$ref"
:host-opaque-ref="host.$ref"
/>
</template>
</ul>
</template>
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import InfraHostItem from "@/components/infra/InfraHostItem.vue";
import { useHostStore } from "@/stores/host.store";
const hostStore = useHostStore();
const { hasError, isLoading, opaqueRefs } = storeToRefs(hostStore);
const { records: hosts, isReady, hasError } = useHostStore().subscribe();
</script>
<style lang="postcss" scoped>

View File

@@ -7,9 +7,9 @@
class="infra-item-label"
v-bind="$attrs"
>
<a :href="href" class="link" @click="navigate">
<a :href="href" class="link" @click="navigate" v-tooltip="hasTooltip">
<UiIcon :icon="icon" class="icon" />
<div class="text">
<div ref="textElement" class="text">
<slot />
</div>
</a>
@@ -22,7 +22,10 @@
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { hasEllipsis } from "@/libs/utils";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { computed, ref } from "vue";
import type { RouteLocationRaw } from "vue-router";
defineProps<{
@@ -30,6 +33,9 @@ defineProps<{
route: RouteLocationRaw;
active?: boolean;
}>();
const textElement = ref<HTMLElement>();
const hasTooltip = computed(() => hasEllipsis(textElement.value));
</script>
<style lang="postcss" scoped>

View File

@@ -1,12 +1,12 @@
<template>
<ul class="infra-pool-list">
<InfraLoadingItem
v-if="isLoading || pool === undefined"
:icon="faBuilding"
/>
<li v-else-if="hasError" class="text-error">
<li v-if="hasError" class="text-error">
{{ $t("error-no-data") }}
</li>
<InfraLoadingItem
v-else-if="!isReady || pool === undefined"
:icon="faBuilding"
/>
<li v-else class="infra-pool-item">
<InfraItemLabel
:icon="faBuilding"
@@ -24,16 +24,14 @@
</template>
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
import InfraHostList from "@/components/infra/InfraHostList.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
import InfraVmList from "@/components/infra/InfraVmList.vue";
import { usePoolStore } from "@/stores/pool.store";
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
const poolStore = usePoolStore();
const { hasError, isLoading, pool } = storeToRefs(poolStore);
const { isReady, hasError, pool } = usePoolStore().subscribe();
</script>
<style lang="postcss" scoped>
@@ -42,7 +40,8 @@ const { hasError, isLoading, pool } = storeToRefs(poolStore);
font-weight: 500;
}
.infra-vm-list:deep(.link) {
.infra-vm-list:deep(.link),
.infra-vm-list:deep(.link-placeholder) {
padding-left: 3rem;
}

View File

@@ -1,12 +1,5 @@
<template>
<li
ref="rootElement"
class="infra-vm-item"
v-tooltip="{
content: vm.name_label,
disabled: isTooltipDisabled,
}"
>
<li v-if="vm !== undefined" ref="rootElement" class="infra-vm-item">
<InfraItemLabel
v-if="isVisible"
:icon="faDisplay"
@@ -15,7 +8,7 @@
{{ vm.name_label || "(VM)" }}
<template #actions>
<InfraAction>
<PowerStateIcon :state="vm?.power_state" />
<PowerStateIcon :state="vm.power_state" />
</InfraAction>
</template>
</InfraItemLabel>
@@ -23,20 +16,20 @@
</template>
<script lang="ts" setup>
import { computed, ref } from "vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { useIntersectionObserver } from "@vueuse/core";
import PowerStateIcon from "@/components/PowerStateIcon.vue";
import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import { hasEllipsis } from "@/libs/utils";
import PowerStateIcon from "@/components/PowerStateIcon.vue";
import { useVmStore } from "@/stores/vm.store";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { useIntersectionObserver } from "@vueuse/core";
import { computed, ref } from "vue";
const props = defineProps<{
vmOpaqueRef: string;
}>();
const { getByOpaqueRef } = useVmStore().subscribe();
const vm = computed(() => getByOpaqueRef(props.vmOpaqueRef));
const rootElement = ref();
const isVisible = ref(false);
@@ -46,13 +39,6 @@ const { stop } = useIntersectionObserver(rootElement, ([entry]) => {
stop();
}
});
const vmStore = useVmStore();
const vm = computed(() => vmStore.getRecord(props.vmOpaqueRef));
const isTooltipDisabled = (target: HTMLElement) =>
!hasEllipsis(target.querySelector(".text"));
</script>
<style lang="postcss" scoped>

View File

@@ -1,38 +1,32 @@
<template>
<ul class="infra-vm-list">
<template v-if="isLoading">
<InfraLoadingItem v-for="i in 3" :icon="faDisplay" :key="i" />
<li v-if="hasError" class="text-error">{{ $t("error-no-data") }}</li>
<template v-else-if="!isReady">
<InfraLoadingItem v-for="i in 3" :key="i" :icon="faDisplay" />
</template>
<p class="text-error" v-else-if="hasError">{{ $t("error-no-data") }}</p>
<InfraVmItem
v-else
v-for="vmOpaqueRef in vmOpaqueRefs"
:key="vmOpaqueRef"
:vm-opaque-ref="vmOpaqueRef"
/>
<InfraVmItem v-for="vm in vms" :key="vm.$ref" :vm-opaque-ref="vm.$ref" />
</ul>
</template>
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import { computed } from "vue";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
import InfraVmItem from "@/components/infra/InfraVmItem.vue";
import { useVmStore } from "@/stores/vm.store";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
const props = defineProps<{
hostOpaqueRef?: string;
}>();
const vmStore = useVmStore();
const { hasError, isLoading, opaqueRefsByHostRef } = storeToRefs(vmStore);
const vmOpaqueRefs = computed(() =>
opaqueRefsByHostRef.value.get(props.hostOpaqueRef ?? "OpaqueRef:NULL")
const { isReady, recordsByHostRef, hasError } = useVmStore().subscribe();
const vms = computed(() =>
recordsByHostRef.value.get(props.hostOpaqueRef ?? "OpaqueRef:NULL")
);
</script>
<style scoped lang="postcss">
<style lang="postcss" scoped>
.text-error {
padding-left: 3rem;
font-weight: 700;

View File

@@ -10,6 +10,7 @@ import { faBuilding } from "@fortawesome/free-regular-svg-icons";
import TitleBar from "@/components/TitleBar.vue";
import { usePoolStore } from "@/stores/pool.store";
const poolStore = usePoolStore();
const name = computed(() => poolStore.pool?.name_label ?? "...");
const { pool } = usePoolStore().subscribe();
const name = computed(() => pool.value?.name_label ?? "...");
</script>

View File

@@ -1,43 +1,39 @@
<template>
<UiTabBar :disabled="!isReady">
<RouterTab :to="{ name: 'pool.dashboard', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.dashboard', params: { uuid: pool?.uuid } }">
{{ $t("dashboard") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.alarms', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.alarms', params: { uuid: pool?.uuid } }">
{{ $t("alarms") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.stats', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.stats', params: { uuid: pool?.uuid } }">
{{ $t("stats") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.system', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.system', params: { uuid: pool?.uuid } }">
{{ $t("system") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.network', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.network', params: { uuid: pool?.uuid } }">
{{ $t("network") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.storage', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.storage', params: { uuid: pool?.uuid } }">
{{ $t("storage") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.tasks', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.tasks', params: { uuid: pool?.uuid } }">
{{ $t("tasks") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.hosts', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.hosts', params: { uuid: pool?.uuid } }">
{{ $t("hosts") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.vms', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.vms', params: { uuid: pool?.uuid } }">
{{ $t("vms") }}
</RouterTab>
</UiTabBar>
</template>
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import { computed } from "vue";
import RouterTab from "@/components/RouterTab.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
import { usePoolStore } from "@/stores/pool.store";
const poolStore = usePoolStore();
const { pool, isReady } = storeToRefs(poolStore);
const poolUuid = computed(() => pool.value?.uuid);
const { pool, isReady } = usePoolStore().subscribe();
</script>

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