Compare commits

..
Author SHA1 Message Date
Julien Fontanet 0ab7692ef9 chore: use npm 2023-07-25 10:26:24 +02:00
Julien Fontanet 14a0caa4c6 fix(xo-web/xoa/licenses): fix message *go TO* 2023-07-25 09:43:11 +02:00
Florent BEAUCHAMP 1c23bd5ff7 feat(read-chunk/readChunkStrict): attach read chunk to error if small text (#6940) 2023-07-20 17:01:26 +02:00
Julien Fontanet 49c161b17a fix(xo-server,xo-web): send version when probing NFS SR
Reported by @benjamreis
2023-07-20 16:46:18 +02:00
Gabriel Gunullu 18dce3fce6 test(fs): fix wrong encryption (#6945) 2023-07-20 16:32:09 +02:00
Julien Fontanet d6fc86b6bc chore(xo-server-transport-xmpp): remove old dep node-xmpp-client
Fix possibly #6942
2023-07-20 10:54:52 +02:00
Florent BEAUCHAMP 61d960d4b1 fix(vmware-explorer): handle snapshot of 1TB+ disks 2023-07-20 10:25:28 +02:00
Florent BEAUCHAMP 02d3465832 feat(vmware-explorer): don't transform stream for raw import in thick mode 2023-07-20 10:25:28 +02:00
Florent BEAUCHAMP 4bbadc9515 feat(vmware-explorer): improve import
- use one stream instead of per block queries if possible
- retry block reading if failing
- handle unaligned end block
2023-07-20 10:25:28 +02:00
Florent BEAUCHAMP 78586291ca fix(vmware-explorer): better disk size computation 2023-07-20 10:25:28 +02:00
Florent BEAUCHAMP 945dec94bf feat(vmware-explorer): retry connection to ESXi 2023-07-20 10:25:28 +02:00
Julien Fontanet 003140d96b test(nbd-client): fix issues introduced by conversion to ESM
Introduced by 7c80d0c1e
2023-07-19 23:09:48 +02:00
Julien Fontanet 363d7cf0d0 fix(node-vsphere-soap): add missing files
Introduced by f0c94496b
2023-07-19 23:02:34 +02:00
Julien Fontanet f0c94496bf chore(node-vsphere-soap): convert to ESM
BREAKING CHANGE
2023-07-19 11:03:56 +02:00
Julien Fontanet de217eabd9 test(nbd-client): fix issues introduced by conversion to ESM
Introduced by 7c80d0c1e
2023-07-19 11:02:12 +02:00
Julien Fontanet 7c80d0c1e1 chore(nbd-client): convert to ESM
BREAKING CHANGE
2023-07-19 10:46:05 +02:00
Julien Fontanet 9fb749b1db chore(fuse-vhd): convert to ESM
BREAKING CHANGE
2023-07-19 10:13:35 +02:00
Julien Fontanet ad9c59669a chore: update dev deps 2023-07-19 10:11:30 +02:00
Julien Fontanet 76a038e403 fix(xo-web): fix doc link to incremental/key backup interval 2023-07-19 09:48:23 +02:00
Julien Fontanet 0e12072922 fix(xo-server/pool.mergeInto): fix IPv6 handling 2023-07-18 17:28:39 +02:00
Julien Fontanet 158a8e14a2 chore(xen-api): expose bracketless IPv6 as hostnameRaw 2023-07-18 17:28:38 +02:00
Julien Fontanet 0c97910349 chore(xo-server): remove unused _mounts property
Introduced by 5c9a47b6b
2023-07-18 11:14:24 +02:00
Florent BEAUCHAMP 8347ac6ed8 fix(xo-server/xapi-stats): simplify caching (#6920)
Following #6903

- change cache system per object => per host
- update cache at the beginning of the query to handle race conditions leading to duplicate requests
- remove concurrency limit (was leading to a huge backlog of queries, and response handling is quite fast)
2023-07-18 09:47:39 +02:00
Mathieu 996abd6e7e fix(xo-web/settings/config): wording fix for XO Config Cloud Backup (#6938)
See zammad#15904
2023-07-13 10:29:36 +02:00
rbarhtaoui de8abd5b63 feat(lite/pool/vms): ability to export selected VMs as JSON file (#6911) 2023-07-13 09:30:10 +02:00
Julien Fontanet 3de928c488 fix(xo-server-audit): ignore mirrorBackup.getAllJobs 2023-07-12 21:52:51 +02:00
Mathieu a2a514e483 feat(lite/stats): cache stats from rrd_update (#6781) 2023-07-12 15:30:05 +02:00
rbarhtaoui ff432e04b0 feat(lite/pool/vms): export selected VMs as CSV file (#6915) 2023-07-12 14:34:16 +02:00
Julien Fontanet 4502590bb0 fix(xapi/VM_create): work-around HVM multiplier issues (#6935)
Fixes zammad#15189
2023-07-12 10:29:14 +02:00
Thierry Goettelmann 6d440a5af5 feat(lite/components): rewrite FormInputWrapper (#6918)
Rewrite `FormInputWrapper` and update `FormInput` to match Figma design.

Slotted input will change color according to passed `warning` and/or `error` message.
2023-07-12 10:20:33 +02:00
Julien Fontanet 0840b4c359 fix(xo-server/rest-api): VDI export via NBD 2023-07-12 10:18:45 +02:00
Julien Fontanet 696ee7dbe5 fix(CHANGELOG): add 5.84.0 highlights
Introduced by f32742225
2023-07-12 10:06:15 +02:00
Thierry Goettelmann 5e23e356ce chore(lite): bundling, dynamic import, optimizations (#6910)
Added dynamic imports for views and components.

Extracted to their own bundle:
- Vue related libs (vue, vue-router, pinia etc.)
- Lodash
- Charts

Removed `vite-plugin-pages` package.

Optimize highlight/markdown loading.
2023-07-12 10:05:09 +02:00
Thierry Goettelmann c705051a89 chore(lite): use injection keys (#6898)
Using injection keys for `provide`/`inject` to prevent errors and code
repetition.
2023-07-11 14:56:03 +02:00
Julien Fontanet ce2b918a29 fix(xo-server): xoData ESModule import
Introduced by c3e0308ad
2023-07-10 18:15:05 +02:00
Julien Fontanet df740b1e8e test(backups): fix issues introduced by conversion to ESM
Introduced by 1005e295b
2023-07-10 16:58:12 +02:00
Julien Fontanet c3e0308ad0 chore(xapi): convert to ESM
BREAKING CHANGE
2023-07-10 16:45:31 +02:00
Julien Fontanet 1005e295b2 chore(backups): convert to ESM
BREAKING CHANGE
2023-07-10 16:45:13 +02:00
Julien Fontanet b3cf58b8c0 fix(complex-matcher): import specific lodash functions (#6904)
This reduces the bundled size when the library is used in a bundled app.
2023-07-10 16:01:54 +02:00
Julien Fontanet 2652c87917 feat(xo-web/backup/restore): can open raw log (#6936) 2023-07-07 11:20:49 +02:00
Thierry Goettelmann 9e0b5575a4 feat(lite/component): new component FormSection (#6926)
Can take a `collapsible` prop in conjunction of a `collapsed` prop.
If `collapsible` is set to `true`, the style is changed and clicking the section
header will toggle the content visibility.
Collapse status updates are sent via the `update:collapsed` event.
This allows to use `collapsed` as a model, e.g.:
`<FormSection collapsible v-model:collapsed="collapsed">`
2023-07-06 15:55:05 +02:00
Thierry Goettelmann 56c089dc01 feat(lite/stories): identify models with an icon and tooltip (#6927)
When a prop is a model, add an indicator (icon + tooltip) to identify it as such.
2023-07-06 15:49:15 +02:00
Julien Fontanet 3b94da1790 docs(supported_hosts): clearer symbol to indicate EOL 2023-07-06 10:41:52 +02:00
Julien Fontanet ec39a8e9fe docs(supported_hosts): issues on XenServer 7.2 2023-07-06 10:39:16 +02:00
Julien Fontanet 6339f971ca docs(supported_hosts): Citrix Hypervisor → XenServer
The name has been reverted back to XenServer.
2023-07-06 10:33:21 +02:00
Pierre Donias 2978ad1486 feat(lite): 0.1.1 (#6930) 2023-07-03 15:58:17 +02:00
Julien Fontanet c0d6dc48de feat(xo-web/XO tasks): better display of start date and duration 2023-07-01 10:30:44 +02:00
Julien Fontanet f327422254 feat: release 5.84.0 2023-06-30 20:09:44 +02:00
Julien Fontanet 938d15d31b feat(xo-web): 5.121.0 2023-06-30 19:22:38 +02:00
Julien Fontanet 5ab1ddb9cb feat(xo-server): 5.118.0 2023-06-30 19:20:29 +02:00
Mathieu 01302d7a60 feat(xo-web/settings/config): cloud backup (#6917) 2023-06-30 19:09:56 +02:00
Julien Fontanet c68630e2d6 feat(xo-server/rest-api): provide a way to extend it 2023-06-30 18:19:09 +02:00
Julien Fontanet db082bfbe9 fix(xo-server/rest-api): handle ids that are numbers instead of strings 2023-06-30 18:19:09 +02:00
Julien Fontanet 650d88db46 feat(xo-server/configurePlugin): can update instead of replace existing config 2023-06-30 18:19:09 +02:00
Julien Fontanet 7d1ecca669 feat(xo-server): consider *passphrase* a sensitive value 2023-06-30 18:19:09 +02:00
Thierry Goettelmann 5f71e629ae fix(lite/components): app-menu doesn't allow more than 1 submenu (#6897) 2023-06-30 15:47:56 +02:00
rbarhtaoui 68205d4676 feat(xo-web/export,import VDI): explicit import/export raw VDI (#6925)
See zammad#15254
2023-06-30 15:10:30 +02:00
Mathieu cdb466225d feat(xo-web,xo-server): import ISO VDI from url (#6924)
Related to zammad#15254
2023-06-30 13:47:43 +02:00
Julien Fontanet 0e7fbd598f feat(docs/rest-api): alpha → beta 2023-06-30 12:00:14 +02:00
Mathieu 99147c893d feat(xo-web): add tooltip on BulkIcons (#6895) 2023-06-29 10:56:26 +02:00
Mathieu c63fb6173d feat(xo-web/import/disk): UI improvement for ISO files (#6874)
See https://xcp-ng.org/forum/topic/7243
2023-06-29 10:51:16 +02:00
Pierre Donias 5932ada717 chore(node-vsphere-soap): make pkg public (#6923)
Make package public and run normalize-packages on it to add the `postversion`
script to its `package.json`.
2023-06-29 10:45:06 +02:00
Mathieu 0d579748d6 fix(lite): replace 'change-power-state' by 'change-state' (#6922) 2023-06-29 10:13:02 +02:00
Pierre Donias 8c5ee4eafe feat: technical release (#6921)
* feat(@xen-orchestra/fs): 4.0.1

* feat(xen-api): 1.3.3

* feat(@vates/nbd-client): 1.2.1

* feat(@vates/node-vsphere-soap): 1.0.0

* feat(@vates/task): 0.2.0

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

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

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

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

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

* feat(xo-cli): 0.20.0

* feat(xo-server): 5.117.0

* feat(xo-server-auth-oidc): 0.3.0

* feat(xo-server-perf-alert): 0.3.6

* feat(xo-web): 5.120.0

* chore(CHANGELOG): update next
2023-06-28 17:10:22 +02:00
Florent BEAUCHAMP b03935ad2f feat(backups): can limit parallel VDI transfers per VM per job (#6787) 2023-06-28 16:47:39 +02:00
Mathieu 38439cbc43 fix(xo-web): enhance RRD stats (#6903)
- fix infinite requests
- avoid duplicate requests
2023-06-28 15:17:00 +02:00
Florent BEAUCHAMP 161c20b534 feat(xo-server): add MBR to cloud-init drive (#6889) 2023-06-28 10:42:01 +02:00
Julien Fontanet 603696dad1 fix(xo-server/rest-api): reply with 204 when non content 2023-06-27 14:43:27 +02:00
Julien Fontanet 6b2ad5a7cc feat(xo-cli rest get): new --output parameter
It can be used to save the response in a file instead of parsing it.
2023-06-27 14:43:27 +02:00
Julien Fontanet 88063d4d87 fix(xo-cli rest): params now support the json: prefix
So that any values can be passed.
2023-06-26 16:21:01 +02:00
Julien Fontanet 8956a99745 feat(xo-cli rest): support patch method 2023-06-26 16:09:32 +02:00
Florent BEAUCHAMP 0f0c0ec0d0 fix(vmware-explorer): handle selef signed certifictae during download (#6908) 2023-06-26 14:24:37 +02:00
Florent BEAUCHAMP e5932e2c33 fix(node-vsphere-soap): don't disable TLS1.2 used by ESXi (#6913) 2023-06-26 11:31:24 +02:00
Julien Fontanet 84ec8f5f3c fix(mixins/HttpProxy): fix premature close warning 2023-06-26 10:47:34 +02:00
Julien Fontanet 661c5a269f fix(mixins/HttpProxy): fix excess event listeners warning 2023-06-26 10:47:31 +02:00
Julien Fontanet 5c6d7cae66 feat(mixins/HttpProxy): debug when proxy is enabled/disabled 2023-06-26 10:41:57 +02:00
Julien Fontanet fcc73859b7 test(node-vsphere-soap): use test
Instead of old `lab` which has a lot of vulnerable dependencies.
2023-06-23 17:42:28 +02:00
Julien Fontanet 36645b0319 test(node-vsphere-soap): use native assert
Instead of old `code` which has a lot of vulnerable dependencies.
2023-06-23 17:35:22 +02:00
a62575e3cf docs(backups): new terminology and mirror backups (#6837)
Co-authored-by: Mathieu <70369997+MathieuRA@users.noreply.github.com>
Co-authored-by: Jon Sands <fohdeesha@gmail.com>
2023-06-23 16:33:10 +02:00
Julien Fontanet d7af3d3c03 fix(CHANGELOG): 5.83.4 → 5.83.3 2023-06-23 14:21:45 +02:00
Julien Fontanet 130ebb7d5f Merge remote-tracking branch 'origin/5.83' 2023-06-23 14:15:34 +02:00
Julien Fontanet 2af845ebd3 feat: release 5.83.3 2023-06-23 11:11:33 +02:00
Julien Fontanet 8e4d1701e6 feat(xo-server): 5.116.4 2023-06-23 11:09:21 +02:00
Julien Fontanet 4d16b6708f feat(@xen-orchestra/proxy): 0.26.28 2023-06-23 11:09:21 +02:00
Julien Fontanet 34ee08be25 feat(@xen-orchestra/backups): 0.38.3 2023-06-23 11:09:20 +02:00
Julien Fontanet d66a76a09e feat(xen-api): 1.3.2 2023-06-23 11:09:02 +02:00
Florent BEAUCHAMP 0d801c9766 fix(backups): fix DR not deleting older VM (#6912)
Introduced by aa36629def
2023-06-23 10:59:51 +02:00
Julien Fontanet b82b676fdb fix(xen-api/transports/json-rpc): fix IPv6 address support
Introduced by ab96c549a
2023-06-23 10:59:27 +02:00
Gabriel GunulluandFlorent BEAUCHAMP 3494c0f64f fix(xo-server-perf-alert): add conditional statement on entry (#6900)
* fix(xo-server-perf-alert): add conditional statement on entry

Test if the entry is null to handle the case where the object cannot be found,
which can happen when the user forgets to remove an element that doesn't exist anymore from
the list of the monitored machines.

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.fr>

---------

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.fr>
2023-06-23 09:04:32 +02:00
Florent BEAUCHAMP 311098adc2 feat(backups): use the right SR for health check during replication (#6902) 2023-06-22 11:35:47 +02:00
Julien Fontanet 58182e2083 fix(xen-api/transports/json-rpc): fix IPv6 address support
Introduced by ab96c549a
2023-06-22 11:08:50 +02:00
Julien Fontanet a62ae43274 feat(xen-api/cli): allow specifying transport 2023-06-22 11:02:15 +02:00
Julien Fontanet f256610e08 fix(xo-web): don't test a disabled remote after editing
Fixes https://team.vates.fr/vates/pl/xxezjup7efr7idcur9qtftcgfe
2023-06-22 08:43:04 +02:00
Gabriel Gunullu 983d048219 feat(xo-web/kubernetes): add version selection (#6880)
Fixes #6842
See xoa#122
2023-06-21 14:10:47 +02:00
Julien Fontanet 3c6033f904 fix(xo-server): close connections of deleted users
Fixes #5235
2023-06-21 12:03:06 +02:00
Julien Fontanet ef2bd2b59d fix(xo-server): better token check on HTTP request
It now checks that the user associated with the authentication token really exists.

This fixes xo-web infinite refresh when the token stored in cookies belongs to a missing user.
2023-06-21 12:03:06 +02:00
Julien Fontanet 04d70e9aa8 chore: update dev deps 2023-06-20 18:09:09 +02:00
Julien Fontanet a2587ffc0a fix(CHANGELOG.unreleased): missing release type for vmware-explorer
Introduced by 4c0506429
2023-06-19 09:40:33 +02:00
Julien Fontanet 6776e7bb3d fix(CHANGELOG.unreleased): missing release type for vmware-explorer
Introduced by 4c0506429
2023-06-19 09:39:53 +02:00
Florent BEAUCHAMP 4c05064294 feat(vmware-exporer): use @vates/node-vsphere-soap 2023-06-19 09:31:07 +02:00
Florent BEAUCHAMP c135f1394f fix(node-vsphere-soap): disable tests since they need a running vsphere/esxi 2023-06-19 09:31:07 +02:00
Florent BEAUCHAMP d68f4215f1 fix(node-vsphere-soap): better handling of self signed cert 2023-06-19 09:31:07 +02:00
Florent BEAUCHAMP af562f3c3a chore(node-vsphere-soap): fix lint issues 2023-06-19 09:31:07 +02:00
Julien Fontanet 7b949716bc chore(node-vsphere-soap): format with Prettier 2023-06-19 09:31:07 +02:00
Florent BEAUCHAMP d3e256289b feat(node-vsphere-soap): fork 2023-06-19 09:31:07 +02:00
Gabriel Gunullu 3688e762b1 fix(xo-web/kubernetes): change recipe description (#6878)
Introduced by eb84d4a7ef
2023-06-16 11:37:35 +02:00
Julien Fontanet 249f1a7af4 feat(backups/XO metadata): store data filename in metadata 2023-06-16 10:40:04 +02:00
Thierry Goettelmann 2de26030ff chore(lite): add type branding to XAPI record's $ref & uuid (#6884)
Type branding enhances our type safety by preventing the incorrect usage of
`XenApiRecord`'s `$ref` and `uuid`. It ensures that these types are not
interchangeable.
2023-06-15 14:01:27 +02:00
Mathieu fcc76fb8d0 fix(xo-web/home): fix 'isHostTimeConsistentWithXoaTime.then is not a function' (#6896)
See xoa-support#15250
Introduced by 132b1a41db
2023-06-15 10:07:56 +02:00
Julien Fontanet 88d5b7095e feat(xo-web/dashboard/health): copiable orphan VDI UUIDs (#6893)
Fixes internal request by @Fohdeesha https://team.vates.fr/vates/pl/p1nsuy8gzpgxtxwrqhdzocpiaw
2023-06-15 09:45:19 +02:00
Julien Fontanet b0e55d88de feat(xo-web): clearer display to choose new backup job type (#6894)
Fixes https://team.vates.fr/vates/pl/xsj49jtmdfgp5god81ninumr6o

- explicit replication
- separate VM and metadata backup types
- homogenize button labels
2023-06-14 10:50:59 +02:00
Mathieu 370ad3e928 feat(lite): implement "closing-confirmation" store (#6883) 2023-06-14 10:45:44 +02:00
rbarhtaoui 07bf77d2dd feat(lite/pool/VMs): ability to delete selected VMs (#6860) 2023-06-14 10:32:15 +02:00
Thierry Goettelmann a5ec65f3c0 fix(lite): eslint error "duplicate key" (#6891) 2023-06-13 13:58:35 +02:00
Thierry Goettelmann 522b318fd9 feat(lite/dev): add keyboard shortcut to toggle language (#6888)
To make development easier, add the ability to toggle language between FR and EN
while in development mode by pressing the `L` key (the same way we can toggle
light/dark theme with `D` key)
2023-06-13 10:45:26 +02:00
Julien Fontanet 9eb2a4033f feat(xo-server-auth-oidc): make scopes configurable and include profile by default
Fixes https://xcp-ng.org/forum/post/62185
2023-06-12 22:22:47 +02:00
Julien Fontanet e87b0c393a chore: update dev deps 2023-06-12 22:00:52 +02:00
Mathieu 1fb7e665fa fix(xo-web/home/pool): switch alert support from 'danger' to 'warning' (#6849)
Harmonize with the host home view.
2023-06-12 11:49:47 +02:00
Thierry Goettelmann 7ea476d787 feat(lite): add alarm store (#6814) 2023-06-12 10:39:37 +02:00
Thierry Goettelmann 8260d07d61 fix(lite/i18n): "coming soon" (#6887) 2023-06-12 10:39:11 +02:00
rbarhtaoui ac0b4e6514 fix(lite/login): fix transparent login button (#6879) 2023-06-12 10:37:47 +02:00
Pierre Donias 27b2f8cf27 docs(netbox): troubleshooting tip for 403 Forbidden (#6882) 2023-06-12 09:42:25 +02:00
Thierry Goettelmann 27b5737f65 feat(lite/pool/VMs): ability to copy selected VMs (#6847) 2023-06-09 14:59:39 +02:00
Julien Fontanet 55b2e0292f docs(task): describe combined task log 2023-06-09 09:45:46 +02:00
Julien Fontanet 464d83e70f feat(xo-web): implement XO task abortion 2023-06-09 09:45:46 +02:00
Julien Fontanet 614255a73a chore(xo-web): remove now unused aborted task status 2023-06-09 09:45:46 +02:00
Julien Fontanet 90d15e1346 feat(task): remove aborted status and add abortionRequested event
BREAKING CHANGE.
2023-06-09 09:45:46 +02:00
Julien Fontanet b0e2ea64e9 feat(xo-server/test.createTask): dynamic name and progress 2023-06-08 14:38:22 +02:00
Julien Fontanet 1da05e239d feat(task): merge custom data into properties
BREAKING CHANGE.

This makes these entries mutable during the life of the task.
2023-06-08 14:38:22 +02:00
Thierry Goettelmann fe7f0db81f feat(lite): revamp XAPI subscription and add immediate option (#6877)
`subscribe()` now accepts an `{ immediate: false }` option.
In this case, the subscription is deferred and can be initialized later with `.start()`.
A `createSubscribe` helper has been added to create an overridden `subscribe` function.
Full documentation has been added to `docs/xen-api-record-stores.md`.
2023-06-08 14:33:38 +02:00
rbarhtaoui 983153e620 feat(lite/pool/tasks): display an error msg if data cannot be fetched (#6777) 2023-06-08 09:21:39 +02:00
Thierry Goettelmann 6fe791dcf2 feat(lite/dashboard): revamp pool dashboard (#6815)
Reworked the pool dashboard to reorder components, simplify the code, and make
the design closer to the Figma mockups.
Added a new `PoolDashboardComingSoon` component for dashboard items that are not
ready yet.
Removed `height: fit-content` from UiCard which should not be needed anymore and
have only recent (~1.4 year) support on Firefox.
2023-06-07 14:41:08 +02:00
Florent BEAUCHAMP 1ad406c7dd test(nbd-client): test secure connection 2023-06-07 10:24:14 +02:00
Florent BEAUCHAMP 4e032e11b1 fix(nbd-client/readBlocks): BigInt handling for default generator 2023-06-07 10:24:14 +02:00
Julien Fontanet ea34516d73 test(vhd-lib): from Jest to test 2023-06-07 10:24:14 +02:00
Thierry Goettelmann e1145f35ee feat(lite): introduce POWER_STATE and VM_OPERATION enums (#6846) 2023-06-07 10:13:29 +02:00
Thierry Goettelmann 6864775b8a fix(lite/AppMenu): AppMenu is not displayed correctly (#6819)
The visibility of AppMenu was previously constrained to its container boundaries
2023-06-07 09:22:27 +02:00
rbarhtaoui f28721b847 feat(lite/pool/VMs): ability to change the VMs power state (#6782) 2023-06-06 15:46:24 +02:00
Julien Fontanet 2dc174fd9d test(task/combineEvents): use variable to ease test maintenance 2023-06-06 10:29:47 +02:00
Julien Fontanet 07142d0410 test(task/combineEvents): test id, start and end properties 2023-06-05 15:29:12 +02:00
Julien Fontanet 41bb16ca30 feat: release 5.83.2 2023-06-01 15:36:48 +02:00
Julien Fontanet d8f1034858 feat: technical release 2023-06-01 14:25:08 +02:00
Julien Fontanet 52b3c49cdb feat(xo-server): 5.116.3 2023-06-01 14:24:58 +02:00
Julien Fontanet c5cb1a5e96 feat(@xen-orchestra/proxy): 0.26.27 2023-06-01 14:24:07 +02:00
Julien Fontanet 92d9d3232c feat(@xen-orchestra/backups): 0.38.2 2023-06-01 14:23:49 +02:00
Florent BEAUCHAMP 9c4e0464f0 fix(backups): fix vm is undefined error (#6873) 2023-06-01 14:21:43 +02:00
Julien Fontanet 72d25754fd feat: release 5.83.1 2023-06-01 12:00:06 +02:00
Julien Fontanet 1465a0ba59 feat(xo-server): 5.116.2 2023-06-01 11:30:47 +02:00
Julien Fontanet ac8ce28286 fix(xo-server): don't require start for Redis collections (2)
Missing changed from fba86bf65
2023-06-01 11:08:52 +02:00
Julien Fontanet c4b06e1915 feat(xo-server): 5.116.1 2023-06-01 10:48:20 +02:00
Julien Fontanet f77675a8a3 feat(@xen-orchestra/proxy): 0.26.26 2023-06-01 10:46:31 +02:00
Julien Fontanet b907c1fd03 feat(@xen-orchestra/backups): 0.38.1 2023-06-01 10:46:15 +02:00
Julien Fontanet fba86bf653 fix(xo-server): don't require start for Redis collections (#6872)
Introduced by 9f3b02036

Redis connection is usable right after starting the core, therefore collections can be created
on the `core started` event and does not require for the (much heavier) `start` hook to run.

This change fixes `xo-server-recover-account`.
2023-06-01 10:36:02 +02:00
Florent BEAUCHAMP b18ebcc38d fix(backups): fix CR not deleting older VM (#6871)
scheduleId was not passed to the writers constructor. It leads to missing scheduleId in metadata (I think there is no consequence), and a bad filter to detect VM to delete after a successfull replication

Users may need to delete manually the VM created that way
2023-06-01 10:33:33 +02:00
Mathieu 4f7f18458e fix(lite/console): fix console not updating when changing VM (#6850)
Introduced by 5237fdd387

`WatchEffect` is called before `Watch` so the connection was "created" then
"cleaned"
2023-05-31 16:26:19 +02:00
Julien Fontanet d412196052 fix(CHANGELOG): badges
Introduced by 1d140d8fd
2023-05-31 16:06:28 +02:00
Julien Fontanet 1d140d8fd2 feat: release 5.83.0 2023-05-31 16:05:18 +02:00
Thierry Goettelmann 6948a25b09 fix(lite/markdown): vue code fence are no longer detected (#6845)
The `vue-template`, `vue-script`, and `vue-style` code fences were no longer
detected, and thus were no longer highlighted.
2023-05-31 15:25:59 +02:00
Julien Fontanet 26131917e3 feat(xo-web): 5.119.1 2023-05-31 11:22:12 +02:00
Mathieu 44a0ab6d0a fix(xo-web/overview): fix isMirrorBackup is not defined (#6870) 2023-05-31 11:06:03 +02:00
Julien Fontanet 2b8b033ad7 feat: technical release 2023-05-31 09:51:53 +02:00
Julien Fontanet 3ee0b3e7df feat(xo-web): 5.119.0 2023-05-31 09:47:42 +02:00
Julien Fontanet 927a55ab30 feat(xo-server): 5.116.0 2023-05-31 09:46:41 +02:00
Julien Fontanet b70721cb60 feat(@xen-orchestra/proxy): 0.26.25 2023-05-31 09:44:14 +02:00
Julien Fontanet f71c820f15 feat(@xen-orchestra/backups-cli): 1.0.8 2023-05-31 09:43:59 +02:00
Julien Fontanet 74e0405a5e feat(@xen-orchestra/backups): 0.38.0 2023-05-31 09:40:48 +02:00
Julien Fontanet 79b55ba30a feat(vhd-lib): 4.5.0 2023-05-31 09:36:01 +02:00
Mathieu ee0adaebc5 feat(xo-web/backup): UI mirror backup implementation (#6858)
See #6854
2023-05-31 09:12:46 +02:00
Julien Fontanet 83c5c976e3 feat(xo-server/rest-api): limit patches listing and RPU (#6864)
Same restriction as in the UI.
2023-05-31 08:49:32 +02:00
Julien Fontanet 18bd2c607e feat(xo-server/backupNg.checkBackup): add basic XO task 2023-05-30 16:51:43 +02:00
Julien Fontanet e2695ce327 fix(xo-server/clearHost): explicit message on missing migration network
Fixes zammad#14882
2023-05-30 16:50:50 +02:00
Florent BEAUCHAMP 3f316fcaea fix(backups): handles task end in CR without health check (#6866) 2023-05-30 16:06:23 +02:00
Florent BEAUCHAMP 8b7b162c76 feat(backups): implement mirror backup 2023-05-30 15:21:53 +02:00
Florent BEAUCHAMP aa36629def refactor(backup/writers): pass the vm and snapshot in transfer/run 2023-05-30 15:21:53 +02:00
Pierre Donias ca345bd6d8 feat(xo-web/task): action to open task REST API URL (#6869) 2023-05-30 14:19:50 +02:00
Florent BEAUCHAMP 61324d10f9 fix(xo-web): VHD directory tooltip (#6865) 2023-05-30 09:27:24 +02:00
Pierre Donias 92fd92ae63 feat(xo-web): XO Tasks (#6861) 2023-05-30 09:20:51 +02:00
Julien Fontanet e48bfa2c88 feat: technical release 2023-05-26 16:50:04 +02:00
Julien Fontanet cd5762fa19 feat(xo-web): 5.118.0 2023-05-26 16:38:38 +02:00
Julien Fontanet 71f7a6cd6c feat(xo-server): 5.115.0 2023-05-26 16:38:38 +02:00
Julien Fontanet b8cade8b7a feat(xo-cli): 0.19.0 2023-05-26 16:38:38 +02:00
Julien Fontanet 696c6f13f0 feat(vhd-cli): 0.9.3 2023-05-26 16:38:38 +02:00
Julien Fontanet b8d923d3ba feat(xo-vmdk-to-vhd): 2.5.5 2023-05-26 16:38:38 +02:00
Julien Fontanet 1a96c1bf0f feat(@xen-orchestra/proxy): 0.26.24 2023-05-26 16:38:38 +02:00
Julien Fontanet 14a01d0141 feat(@xen-orchestra/mixins): 0.10.1 2023-05-26 16:38:38 +02:00
Julien Fontanet 74a2a4d2e5 feat(@xen-orchestra/backups-cli): 1.0.7 2023-05-26 16:38:38 +02:00
Julien Fontanet b13b44cfd0 feat(@xen-orchestra/backups): 0.37.0 2023-05-26 16:38:38 +02:00
Julien Fontanet 50a164423a feat(@xen-orchestra/xapi): 2.2.1 2023-05-26 16:38:38 +02:00
Julien Fontanet a40d50a3bd feat(vhd-lib): 4.4.1 2023-05-26 16:38:38 +02:00
Julien Fontanet 529e33140a feat(@xen-orchestra/fs): 4.0.0 2023-05-26 16:38:38 +02:00
Mathieu 132b1a41db fix(xo-web/host-item): display alert in host-item for host inconsistent time (#6833)
See xoa-support#14626
Introduced by aadc1bb84c
2023-05-26 16:17:04 +02:00
Julien Fontanet 75948b2977 feat(xo-server/rest-api): endpoints to list pools/hosts missing patches 2023-05-26 16:11:11 +02:00
Gabriel Gunullu eb84d4a7ef feat(xo-web/kubernetes): add number of cp choice (#6809)
See xoa#120
2023-05-26 16:08:11 +02:00
Julien Fontanet 1816d0240e refactor(fs): separate internal and public interfaces
Public interfaces may be decorated with behaviors (e.g. concurrency limits, path rewriting) which
makes them unsuitable from being called from inside the class or its children.

Internal interfaces are now prefixed with `__`.
2023-05-26 15:32:56 +02:00
Julien Fontanet 2c6d36b63e refactor(fs): use private fields where appropriate 2023-05-26 15:32:56 +02:00
Mathieu d9776ae8ed fix(xo-web): fix various 'an error has occurred' (#6848)
See xoa-support#14631
2023-05-26 14:45:29 +02:00
Florent BEAUCHAMP b456394663 refactor(backups): extract method forkDeltaExport 2023-05-26 13:01:15 +02:00
Florent BEAUCHAMP 94f599bdbd refactor(backups/RemoteAdapter): extract method listAllVms 2023-05-26 13:01:08 +02:00
Florent BEAUCHAMP d466ca143a refactor(backups/runner): Vms -> VmsXapi 2023-05-26 12:48:56 +02:00
Florent BEAUCHAMP 78ed85a49f feat(backups): add ability to read only one delta instead of the full chain 2023-05-26 12:47:42 +02:00
Florent BEAUCHAMP c24e7f9ecd refactor(backup/remoteAdapter): readDeltaVmBackup -> readIncrementalVmBackup 2023-05-26 12:24:56 +02:00
Mathieu 98caa89625 feat(xo-web/self): add default tags for self service users (#6810)
See #6812

Add default tags for Self Service users.
2023-05-26 11:45:05 +02:00
Pierre Donias 8e176eadb1 fix(xo-web): show Suse icon when distro name is opensuse (#6852)
See #6676
See #6746
See https://xcp-ng.org/forum/topic/6965
2023-05-26 09:24:30 +02:00
Julien Fontanet 444268406f fix(mixins/Tasks): update updatedAt when marking tasks as interrupted 2023-05-25 16:06:09 +02:00
Thierry Goettelmann 7e062977d0 feat(lite/component): add new Vue component UiCardSpinner (#6806)
`UiSpinner` is often used to add a spinner inside an `UiCard`, applying similar
styles. This `UiCardSpinner` component creates a homogeneous spinner to use in
theses cases.
2023-05-25 14:00:23 +02:00
Mathieu f4bf56f159 feat(xo-web/self): ability to share VMs by default (#6838)
See xoa-support#7420
2023-05-25 11:00:04 +02:00
Julien Fontanet 9f3b020361 fix(xo-server): create collection after connected to Redis
Introduced by 36b94f745

Redis is now connected in `start core` hook and should not be used before.

Some minor initialization stuff (namespace and version registration) where failing silently before
this fix.
2023-05-24 17:40:20 +02:00
Julien Fontanet ef35021a44 chore(backups,xo-server): use extractOpaqueRef from @xen-orchestra/xapi
Instead of custom implementations.
2023-05-24 12:09:42 +02:00
Julien Fontanet b74ebd050a feat(xapi/extractOpaqueRef): expose it publicly 2023-05-24 12:07:54 +02:00
Julien Fontanet 8a16d6aa3b feat(xapi/extractOpaqueRef): add searched string to error
Helps debugging.
2023-05-24 12:07:22 +02:00
Julien Fontanet cf7393992c chore(xapi/extractOpaqueRef): named function for better stacktraces 2023-05-24 12:05:56 +02:00
Thierry Goettelmann c576114dad feat(lite): new FormInputGroup component (#6740) 2023-05-23 16:58:39 +02:00
Julien Fontanet deeb399046 feat(xo-server/rest-api): rolling_update pool action 2023-05-23 15:35:32 +02:00
Julien Fontanet 9cf8f8f492 chore(xo-server/rest-api): also pass xoObject to actions 2023-05-23 15:35:32 +02:00
Julien Fontanet 28b7e99ebc chore(xo-server): move RPU logic from API layer to XenServers mixin 2023-05-23 15:35:32 +02:00
rbarhtaoui 0ba729e5b9 feat(lite/pool/dashboard): display error message when data is not fetched (#6776) 2023-05-23 14:40:43 +02:00
Florent BEAUCHAMP ac8c146cf7 refactor(backups): separate full and incremental VM runners 2023-05-23 09:27:47 +02:00
Florent BEAUCHAMP 2ba437be31 refactor(backups): separate VMs and metadata runners 2023-05-23 09:27:47 +02:00
Florent BEAUCHAMP bd8bb73309 refactor(backups): move Runner, VmBackup, writers and specific method to a private folder 2023-05-23 09:27:47 +02:00
Florent BEAUCHAMP 485c2f4669 refactor(backups/Backup.createRunner): factory
BREAKING CHANGE: Backup can no longer be instantiated directly.
2023-05-23 09:27:47 +02:00
Florent BEAUCHAMP 6fb562d92f refactor(backups/Backup): extract getAdaptersByRemote, RemoteTimeoutError and runTasks 2023-05-23 09:27:47 +02:00
Florent BEAUCHAMP 85efdcf7b9 refactor(backups/_incrementalVm): delta → incremental 2023-05-23 09:27:47 +02:00
Florent BEAUCHAMP fc1357d5d6 refactor(backups): _deltaVm → _incrementalVm 2023-05-23 09:27:47 +02:00
Florent BEAUCHAMP 88b015bda4 refactor(backups/writers) : replication → xapi 2023-05-23 09:27:47 +02:00
Florent BEAUCHAMP b46f76cccf refactor(backups/writers): backup → remote 2023-05-23 09:27:47 +02:00
Florent BEAUCHAMP c3bb2185c2 refactor(backups/writers): delta → incremental 2023-05-23 09:27:47 +02:00
Florent BEAUCHAMP a240853fe0 refactor(backups/_VmBackup): delta → incremental 2023-05-23 09:27:47 +02:00
Thierry Goettelmann d7ce609940 chore(lite): upgrade dependencies (#6843) 2023-05-22 10:41:39 +02:00
Florent BEAUCHAMP 1b0ec9839e fix(xo-server): import OVA with broken VMDK size in metadata (#6824)
ova generated from oracle virtualization server seems to have the size of the vmdk
instead of the disk size in the metadata

this will cause the transfer to fail when the import try to write data

after the size of the vmdk, for example a 50GB disk make a 10GB vmdk. It will fail when import reach data in the 10-50GB range
2023-05-22 10:20: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 https://github.com/vatesfr/xen-orchestra/commit/3f73138fc34949bd8454498884787b75329258e1
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
453 changed files with 60441 additions and 27352 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ module.exports = {
}, },
}, },
{ {
files: ['*.{spec,test}.{,c,m}js'], files: ['*.{integ,spec,test}.{,c,m}js'],
rules: { rules: {
'n/no-unpublished-require': 'off', 'n/no-unpublished-require': 'off',
'n/no-unpublished-import': 'off', 'n/no-unpublished-import': 'off',
+5 -5
View File
@@ -21,12 +21,12 @@ jobs:
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: '18' node-version: '18'
cache: 'yarn' cache: 'npm'
- name: Install project dependencies - name: Install project dependencies
run: yarn run: npm ci
- name: Build the project - name: Build the project
run: yarn build run: npm run build
- name: Lint tests - name: Lint tests
run: yarn test-lint run: npm run test-lint
- name: Integration tests - name: Integration tests
run: sudo yarn test-integration run: sudo npm run test-integration
@@ -1,9 +1,7 @@
'use strict' import LRU from 'lru-cache'
import Fuse from 'fuse-native'
const LRU = require('lru-cache') import { VhdSynthetic } from 'vhd-lib'
const Fuse = require('fuse-native') import { Disposable, fromCallback } from 'promise-toolbox'
const { VhdSynthetic } = require('vhd-lib')
const { Disposable, fromCallback } = require('promise-toolbox')
// build a s stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js // build a s stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
const stat = st => ({ const stat = st => ({
@@ -16,7 +14,7 @@ const stat = st => ({
gid: st.gid !== undefined ? st.gid : process.getgid(), gid: st.gid !== undefined ? st.gid : process.getgid(),
}) })
exports.mount = Disposable.factory(async function* mount(handler, diskPath, mountDir) { export const mount = Disposable.factory(async function* mount(handler, diskPath, mountDir) {
const vhd = yield VhdSynthetic.fromVhdChain(handler, diskPath) const vhd = yield VhdSynthetic.fromVhdChain(handler, diskPath)
const cache = new LRU({ const cache = new LRU({
+3 -2
View File
@@ -15,13 +15,14 @@
"url": "https://vates.fr" "url": "https://vates.fr"
}, },
"engines": { "engines": {
"node": ">=10.0" "node": ">=14"
}, },
"main": "./index.mjs",
"dependencies": { "dependencies": {
"fuse-native": "^2.2.6", "fuse-native": "^2.2.6",
"lru-cache": "^7.14.0", "lru-cache": "^7.14.0",
"promise-toolbox": "^0.21.0", "promise-toolbox": "^0.21.0",
"vhd-lib": "^4.4.0" "vhd-lib": "^4.5.0"
}, },
"scripts": { "scripts": {
"postversion": "npm publish --access public" "postversion": "npm publish --access public"
-42
View File
@@ -1,42 +0,0 @@
'use strict'
exports.INIT_PASSWD = Buffer.from('NBDMAGIC') // "NBDMAGIC" ensure we're connected to a nbd server
exports.OPTS_MAGIC = Buffer.from('IHAVEOPT') // "IHAVEOPT" start an option block
exports.NBD_OPT_REPLY_MAGIC = 1100100111001001n // magic received during negociation
exports.NBD_OPT_EXPORT_NAME = 1
exports.NBD_OPT_ABORT = 2
exports.NBD_OPT_LIST = 3
exports.NBD_OPT_STARTTLS = 5
exports.NBD_OPT_INFO = 6
exports.NBD_OPT_GO = 7
exports.NBD_FLAG_HAS_FLAGS = 1 << 0
exports.NBD_FLAG_READ_ONLY = 1 << 1
exports.NBD_FLAG_SEND_FLUSH = 1 << 2
exports.NBD_FLAG_SEND_FUA = 1 << 3
exports.NBD_FLAG_ROTATIONAL = 1 << 4
exports.NBD_FLAG_SEND_TRIM = 1 << 5
exports.NBD_FLAG_FIXED_NEWSTYLE = 1 << 0
exports.NBD_CMD_FLAG_FUA = 1 << 0
exports.NBD_CMD_FLAG_NO_HOLE = 1 << 1
exports.NBD_CMD_FLAG_DF = 1 << 2
exports.NBD_CMD_FLAG_REQ_ONE = 1 << 3
exports.NBD_CMD_FLAG_FAST_ZERO = 1 << 4
exports.NBD_CMD_READ = 0
exports.NBD_CMD_WRITE = 1
exports.NBD_CMD_DISC = 2
exports.NBD_CMD_FLUSH = 3
exports.NBD_CMD_TRIM = 4
exports.NBD_CMD_CACHE = 5
exports.NBD_CMD_WRITE_ZEROES = 6
exports.NBD_CMD_BLOCK_STATUS = 7
exports.NBD_CMD_RESIZE = 8
exports.NBD_REQUEST_MAGIC = 0x25609513 // magic number to create a new NBD request to send to the server
exports.NBD_REPLY_MAGIC = 0x67446698 // magic number received from the server when reading response to a nbd request
exports.NBD_REPLY_ACK = 1
exports.NBD_DEFAULT_PORT = 10809
exports.NBD_DEFAULT_BLOCK_SIZE = 64 * 1024
+41
View File
@@ -0,0 +1,41 @@
export const INIT_PASSWD = Buffer.from('NBDMAGIC') // "NBDMAGIC" ensure we're connected to a nbd server
export const OPTS_MAGIC = Buffer.from('IHAVEOPT') // "IHAVEOPT" start an option block
export const NBD_OPT_REPLY_MAGIC = 1100100111001001n // magic received during negociation
export const NBD_OPT_EXPORT_NAME = 1
export const NBD_OPT_ABORT = 2
export const NBD_OPT_LIST = 3
export const NBD_OPT_STARTTLS = 5
export const NBD_OPT_INFO = 6
export const NBD_OPT_GO = 7
export const NBD_FLAG_HAS_FLAGS = 1 << 0
export const NBD_FLAG_READ_ONLY = 1 << 1
export const NBD_FLAG_SEND_FLUSH = 1 << 2
export const NBD_FLAG_SEND_FUA = 1 << 3
export const NBD_FLAG_ROTATIONAL = 1 << 4
export const NBD_FLAG_SEND_TRIM = 1 << 5
export const NBD_FLAG_FIXED_NEWSTYLE = 1 << 0
export const NBD_CMD_FLAG_FUA = 1 << 0
export const NBD_CMD_FLAG_NO_HOLE = 1 << 1
export const NBD_CMD_FLAG_DF = 1 << 2
export const NBD_CMD_FLAG_REQ_ONE = 1 << 3
export const NBD_CMD_FLAG_FAST_ZERO = 1 << 4
export const NBD_CMD_READ = 0
export const NBD_CMD_WRITE = 1
export const NBD_CMD_DISC = 2
export const NBD_CMD_FLUSH = 3
export const NBD_CMD_TRIM = 4
export const NBD_CMD_CACHE = 5
export const NBD_CMD_WRITE_ZEROES = 6
export const NBD_CMD_BLOCK_STATUS = 7
export const NBD_CMD_RESIZE = 8
export const NBD_REQUEST_MAGIC = 0x25609513 // magic number to create a new NBD request to send to the server
export const NBD_REPLY_MAGIC = 0x67446698 // magic number received from the server when reading response to a nbd request
export const NBD_REPLY_ACK = 1
export const NBD_DEFAULT_PORT = 10809
export const NBD_DEFAULT_BLOCK_SIZE = 64 * 1024
@@ -1,8 +1,11 @@
'use strict' import assert from 'node:assert'
const assert = require('node:assert') import { Socket } from 'node:net'
const { Socket } = require('node:net') import { connect } from 'node:tls'
const { connect } = require('node:tls') import { fromCallback, pRetry, pDelay, pTimeout } from 'promise-toolbox'
const { import { readChunkStrict } from '@vates/read-chunk'
import { createLogger } from '@xen-orchestra/log'
import {
INIT_PASSWD, INIT_PASSWD,
NBD_CMD_READ, NBD_CMD_READ,
NBD_DEFAULT_BLOCK_SIZE, NBD_DEFAULT_BLOCK_SIZE,
@@ -17,16 +20,13 @@ const {
NBD_REQUEST_MAGIC, NBD_REQUEST_MAGIC,
OPTS_MAGIC, OPTS_MAGIC,
NBD_CMD_DISC, NBD_CMD_DISC,
} = require('./constants.js') } from './constants.mjs'
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') const { warn } = createLogger('vates:nbd-client')
// documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md // documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
module.exports = class NbdClient { export default class NbdClient {
#serverAddress #serverAddress
#serverCert #serverCert
#serverPort #serverPort
@@ -313,8 +313,8 @@ module.exports = class NbdClient {
const exportSize = this.#exportSize const exportSize = this.#exportSize
const chunkSize = 2 * 1024 * 1024 const chunkSize = 2 * 1024 * 1024
indexGenerator = function* () { indexGenerator = function* () {
const nbBlocks = Math.ceil(exportSize / chunkSize) const nbBlocks = Math.ceil(Number(exportSize / BigInt(chunkSize)))
for (let index = 0; index < nbBlocks; index++) { for (let index = 0; BigInt(index) < nbBlocks; index++) {
yield { index, size: chunkSize } yield { index, size: chunkSize }
} }
} }
-76
View File
@@ -1,76 +0,0 @@
'use strict'
const NbdClient = require('./index.js')
const { spawn } = require('node:child_process')
const fs = require('node:fs/promises')
const { test } = require('tap')
const tmp = require('tmp')
const { pFromCallback } = require('promise-toolbox')
const { asyncEach } = require('@vates/async-each')
const FILE_SIZE = 2 * 1024 * 1024
async function createTempFile(size) {
const tmpPath = await pFromCallback(cb => tmp.file(cb))
const data = Buffer.alloc(size, 0)
for (let i = 0; i < size; i += 4) {
data.writeUInt32BE(i, i)
}
await fs.writeFile(tmpPath, data)
return tmpPath
}
test('it works with unsecured network', async tap => {
const path = await createTempFile(FILE_SIZE)
const nbdServer = spawn(
'nbdkit',
[
'file',
path,
'--newstyle', //
'--exit-with-parent',
'--read-only',
'--export-name=MY_SECRET_EXPORT',
],
{
stdio: ['inherit', 'inherit', 'inherit'],
}
)
const client = new NbdClient({
address: 'localhost',
exportname: 'MY_SECRET_EXPORT',
secure: false,
})
await client.connect()
tap.equal(client.exportSize, BigInt(FILE_SIZE))
const CHUNK_SIZE = 128 * 1024 // non default size
const indexes = []
for (let i = 0; i < FILE_SIZE / CHUNK_SIZE; i++) {
indexes.push(i)
}
// read mutiple blocks in parallel
await asyncEach(
indexes,
async i => {
const block = await client.readBlock(i, CHUNK_SIZE)
let blockOk = true
let firstFail
for (let j = 0; j < CHUNK_SIZE; j += 4) {
const wanted = i * CHUNK_SIZE + j
const found = block.readUInt32BE(j)
blockOk = blockOk && found === wanted
if (!blockOk && firstFail === undefined) {
firstFail = j
}
}
tap.ok(blockOk, `check block ${i} content`)
},
{ concurrency: 8 }
)
await client.disconnect()
nbdServer.kill()
await fs.unlink(path)
})
+4 -3
View File
@@ -13,17 +13,18 @@
"url": "https://vates.fr" "url": "https://vates.fr"
}, },
"license": "ISC", "license": "ISC",
"version": "1.2.0", "version": "1.2.1",
"engines": { "engines": {
"node": ">=14.0" "node": ">=14.0"
}, },
"main": "./index.mjs",
"dependencies": { "dependencies": {
"@vates/async-each": "^1.0.0", "@vates/async-each": "^1.0.0",
"@vates/read-chunk": "^1.1.1", "@vates/read-chunk": "^1.1.1",
"@xen-orchestra/async-map": "^0.1.2", "@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0", "@xen-orchestra/log": "^0.6.0",
"promise-toolbox": "^0.21.0", "promise-toolbox": "^0.21.0",
"xen-api": "^1.3.0" "xen-api": "^1.3.3"
}, },
"devDependencies": { "devDependencies": {
"tap": "^16.3.0", "tap": "^16.3.0",
@@ -31,6 +32,6 @@
}, },
"scripts": { "scripts": {
"postversion": "npm publish --access public", "postversion": "npm publish --access public",
"test-integration": "tap *.integ.js" "test-integration": "tap --lines 97 --functions 95 --branches 74 --statements 97 tests/*.integ.mjs"
} }
} }
+182
View File
@@ -0,0 +1,182 @@
Public Key Info:
Public Key Algorithm: RSA
Key Security Level: High (3072 bits)
modulus:
00:be:92:be:df:de:0a:ab:38:fc:1a:c0:1a:58:4d:86
b8:1f:25:10:7d:19:05:17:bf:02:3d:e9:ef:f8:c0:04
5d:6f:98:de:5c:dd:c3:0f:e2:61:61:e4:b5:9c:42:ac
3e:af:fd:30:10:e1:54:32:66:75:f6:80:90:85:05:a0
6a:14:a2:6f:a7:2e:f0:f3:52:94:2a:f2:34:fc:0d:b4
fb:28:5d:1c:11:5c:59:6e:63:34:ba:b3:fd:73:b1:48
35:00:84:53:da:6a:9b:84:ab:64:b1:a1:2b:3a:d1:5a
d7:13:7c:12:2a:4e:72:e9:96:d6:30:74:c5:71:05:14
4b:2d:01:94:23:67:4e:37:3c:1e:c1:a0:bc:34:04:25
21:11:fb:4b:6b:53:74:8f:90:93:57:af:7f:3b:78:d6
a4:87:fe:7d:ed:20:11:8b:70:54:67:b8:c9:f5:c0:6b
de:4e:e7:a5:79:ff:f7:ad:cf:10:57:f5:51:70:7b:54
68:28:9e:b9:c2:10:7b:ab:aa:11:47:9f:ec:e6:2f:09
44:4a:88:5b:dd:8c:10:b4:c4:03:25:06:d9:e0:9f:a0
0d:cf:94:4b:3b:fa:a5:17:2c:e4:67:c4:17:6a:ab:d8
c8:7a:16:41:b9:91:b7:9c:ae:8c:94:be:26:61:51:71
c1:a6:39:39:97:75:28:a9:0e:21:ea:f0:bd:71:4a:8c
e1:f8:1d:a9:22:2f:10:a8:1b:e5:a4:9a:fd:0f:fa:c6
20:bc:96:99:79:c6:ba:a4:1f:3e:d4:91:c5:af:bb:71
0a:5a:ef:69:9c:64:69:ce:5a:fe:3f:c2:24:f4:26:d4
3d:ab:ab:9a:f0:f6:f1:b1:64:a9:f4:e2:34:6a:ab:2e
95:47:b9:07:5a:39:c6:95:9c:a9:e8:ed:71:dd:c1:21
16:c8:2d:4c:2c:af:06:9d:c6:fa:fe:c5:2a:6c:b4:c3
d5:96:fc:5e:fd:ec:1c:30:b4:9d:cb:29:ef:a8:50:1c
21:
public exponent:
01:00:01:
private exponent:
25:37:c5:7d:35:01:02:65:73:9e:c9:cb:9b:59:30:a9
3e:b3:df:5f:7f:06:66:97:d0:19:45:59:af:4b:d8:ce
62:a0:09:35:3b:bd:ff:99:27:89:95:bf:fe:0f:6b:52
26:ce:9c:97:7f:5a:11:29:bf:79:ef:ab:c9:be:ca:90
4d:0d:58:1e:df:65:01:30:2c:6d:a2:b5:c4:4f:ec:fb
6b:eb:9b:32:ac:c5:6e:70:83:78:be:f4:0d:a7:1e:c1
f3:22:e4:b9:70:3e:85:0f:6f:ef:dc:d8:f3:78:b5:73
f1:83:36:8c:fa:9b:28:91:63:ad:3c:f0:de:5c:ae:94
eb:ea:36:03:20:06:bf:74:c7:50:eb:52:36:1a:65:21
eb:40:17:7f:93:61:dd:33:d0:02:bc:ec:6d:31:f1:41
5a:a9:d1:f0:00:66:4c:c4:18:47:d5:67:e3:cd:bb:83
44:07:ab:62:83:21:dc:d8:e6:89:37:08:bb:9d:ea:62
c2:5d:ce:85:c2:dc:48:27:0c:a4:23:61:b7:30:e7:26
44:dc:1e:5c:2e:16:35:2b:2e:a6:e6:a4:ce:1f:9b:e9
fe:96:fa:49:1d:fb:2a:df:bc:bf:46:da:52:f8:37:8a
84:ab:e4:73:e6:46:56:b5:b4:3d:e1:63:eb:02:8e:d7
67:96:c4:dc:28:6d:6b:b6:0c:a3:0b:db:87:29:ad:f9
ec:73:b6:55:a3:40:32:13:84:c7:2f:33:74:04:dc:42
00:11:9c:fb:fc:62:35:b3:82:c3:3c:28:80:e8:09:a8
97:c7:c1:2e:3d:27:fa:4f:9b:fc:c2:34:58:41:5c:a1
e2:70:2e:2f:82:ad:bd:bd:8e:dd:23:12:25:de:89:70
60:75:48:90:80:ac:55:74:51:6f:49:9e:7f:63:41:8b
3c:b1:f5:c3:6b:4b:5a:50:a6:4d:38:e8:82:c2:04:c8
30:fd:06:9b:c1:04:27:b6:63:3a:5e:f5:4d:00:c3:d1
prime1:
00:f6:00:2e:7d:89:61:24:16:5e:87:ca:18:6c:03:b8
b4:33:df:4a:a7:7f:db:ed:39:15:41:12:61:4f:4e:b4
de:ab:29:d9:0c:6c:01:7e:53:2e:ee:e7:5f:a2:e4:6d
c6:4b:07:4e:d8:a3:ae:45:06:97:bd:18:a3:e9:dd:29
54:64:6d:f0:af:08:95:ae:ae:3e:71:63:76:2a:a1:18
c4:b1:fc:bc:3d:42:15:74:b3:c5:38:1f:5d:92:f1:b2
c6:3f:10:fe:35:1a:c6:b1:ce:70:38:ff:08:5c:de:61
79:c7:50:91:22:4d:e9:c8:18:49:e2:5c:91:84:86:e2
4d:0f:6e:9b:0d:81:df:aa:f3:59:75:56:e9:33:18:dd
ab:39:da:e2:25:01:05:a1:6e:23:59:15:2c:89:35:c7
ae:9c:c7:ea:88:9a:1a:f3:48:07:11:82:59:79:8c:62
53:06:37:30:14:b3:82:b1:50:fc:ae:b8:f7:1c:57:44
7d:
prime2:
00:c6:51:cc:dc:88:2e:cf:98:90:10:19:e0:d3:a4:d1
3f:dc:b0:29:d3:bb:26:ee:eb:00:17:17:d1:d1:bb:9b
34:b1:4e:af:b5:6c:1c:54:53:b4:bb:55:da:f7:78:cd
38:b4:2e:3a:8c:63:80:3b:64:9c:b4:2b:cd:dd:50:0b
05:d2:00:7a:df:8e:c3:e6:29:e0:9c:d8:40:b7:11:09
f4:38:df:f6:ed:93:1e:18:d4:93:fa:8d:ee:82:9c:0f
c1:88:26:84:9d:4f:ae:8a:17:d5:55:54:4c:c6:0a:ac
4d:ec:33:51:68:0f:4b:92:2e:04:57:fe:15:f5:00:46
5c:8e:ad:09:2c:e7:df:d5:36:7a:4e:bd:da:21:22:d7
58:b4:72:93:94:af:34:cc:e2:b8:d0:4f:0b:5d:97:08
12:19:17:34:c5:15:49:00:48:56:13:b8:45:4e:3b:f8
bc:d5:ab:d9:6d:c2:4a:cc:01:1a:53:4d:46:50:49:3b
75:
coefficient:
63:67:50:29:10:6a:85:a3:dc:51:90:20:76:86:8c:83
8e:d5:ff:aa:75:fd:b5:f8:31:b0:96:6c:18:1d:5b:ed
a4:2e:47:8d:9c:c2:1e:2c:a8:6d:4b:10:a5:c2:53:46
8a:9a:84:91:d7:fc:f5:cc:03:ce:b9:3d:5c:01:d2:27
99:7b:79:89:4f:a1:12:e3:05:5d:ee:10:f6:8c:e6:ce
5e:da:32:56:6d:6f:eb:32:b4:75:7b:94:49:d8:2d:9e
4d:19:59:2e:e4:0b:bc:95:df:df:65:67:a1:dd:c6:2b
99:f4:76:e8:9f:fa:57:1d:ca:f9:58:a9:ce:9b:30:5c
42:8a:ba:05:e7:e2:15:45:25:bc:e9:68:c1:8b:1a:37
cc:e1:aa:45:2e:94:f5:81:47:1e:64:7f:c0:c1:b7:a8
21:58:18:a9:a0:ed:e0:27:75:bf:65:81:6b:e4:1d:5a
b7:7e:df:d8:28:c6:36:21:19:c8:6e:da:ca:9e:da:84
exp1:
00:ba:d7:fe:77:a9:0d:98:2c:49:56:57:c0:5e:e2:20
ba:f6:1f:26:03:bc:d0:5d:08:9b:45:16:61:c4:ab:e2
22:b1:dc:92:17:a6:3d:28:26:a4:22:1e:a8:7b:ff:86
05:33:5d:74:9c:85:0d:cb:2d:ab:b8:9b:6b:7c:28:57
c8:da:92:ca:59:17:6b:21:07:05:34:78:37:fb:3e:ea
a2:13:12:04:23:7e:fa:ee:ed:cf:e0:c5:a9:fb:ff:0a
2b:1b:21:9c:02:d7:b8:8c:ba:60:70:59:fc:8f:14:f4
f2:5a:d9:ad:b2:61:7d:2c:56:8e:5f:98:b1:89:f8:2d
10:1c:a5:84:ad:28:b4:aa:92:34:a3:34:04:e1:a3:84
52:16:1a:52:e3:8a:38:2d:99:8a:cd:91:90:87:12:ca
fc:ab:e6:08:14:03:00:6f:41:88:e4:da:9d:7c:fd:8c
7c:c4:de:cb:ed:1d:3f:29:d0:7a:6b:76:df:71:ae:32
bd:
exp2:
4a:e9:d3:6c:ea:b4:64:0e:c9:3c:8b:c9:f5:a8:a8:b2
6a:f6:d0:95:fe:78:32:7f:ea:c4:ce:66:9f:c7:32:55
b1:34:7c:03:18:17:8b:73:23:2e:30:bc:4a:07:03:de
8b:91:7a:e4:55:21:b7:4d:c6:33:f8:e8:06:d5:99:94
55:43:81:26:b9:93:1e:7a:6b:32:54:2d:fd:f9:1d:bd
77:4e:82:c4:33:72:87:06:a5:ef:5b:75:e1:38:7a:6b
2c:b7:00:19:3c:64:3e:1d:ca:a4:34:f7:db:47:64:d6
fa:86:58:15:ea:d1:2d:22:dc:d9:30:4d:b3:02:ab:91
83:03:b2:17:98:6f:60:e6:f7:44:8f:4a:ba:81:a2:bf
0b:4a:cc:9c:b9:a2:44:52:d0:65:3f:b6:97:5f:d9:d8
9c:49:bb:d1:46:bd:10:b2:42:71:a8:85:e5:8b:99:e6
1b:00:93:5d:76:ab:32:6c:a8:39:17:53:9c:38:4d:91
Public Key PIN:
pin-sha256:ISh/UeFjUG5Gwrpx6hMUGQPvg9wOKjOkHmRbs4YjZqs=
Public Key ID:
sha256:21287f51e163506e46c2ba71ea13141903ef83dc0e2a33a41e645bb3862366ab
sha1:1a48455111ac45fb5807c5cdb7b20b896c52f0b6
-----BEGIN RSA PRIVATE KEY-----
MIIG4wIBAAKCAYEAvpK+394Kqzj8GsAaWE2GuB8lEH0ZBRe/Aj3p7/jABF1vmN5c
3cMP4mFh5LWcQqw+r/0wEOFUMmZ19oCQhQWgahSib6cu8PNSlCryNPwNtPsoXRwR
XFluYzS6s/1zsUg1AIRT2mqbhKtksaErOtFa1xN8EipOcumW1jB0xXEFFEstAZQj
Z043PB7BoLw0BCUhEftLa1N0j5CTV69/O3jWpIf+fe0gEYtwVGe4yfXAa95O56V5
//etzxBX9VFwe1RoKJ65whB7q6oRR5/s5i8JREqIW92MELTEAyUG2eCfoA3PlEs7
+qUXLORnxBdqq9jIehZBuZG3nK6MlL4mYVFxwaY5OZd1KKkOIerwvXFKjOH4Haki
LxCoG+Wkmv0P+sYgvJaZeca6pB8+1JHFr7txClrvaZxkac5a/j/CJPQm1D2rq5rw
9vGxZKn04jRqqy6VR7kHWjnGlZyp6O1x3cEhFsgtTCyvBp3G+v7FKmy0w9WW/F79
7BwwtJ3LKe+oUBwhAgMBAAECggGAJTfFfTUBAmVznsnLm1kwqT6z319/BmaX0BlF
Wa9L2M5ioAk1O73/mSeJlb/+D2tSJs6cl39aESm/ee+ryb7KkE0NWB7fZQEwLG2i
tcRP7Ptr65syrMVucIN4vvQNpx7B8yLkuXA+hQ9v79zY83i1c/GDNoz6myiRY608
8N5crpTr6jYDIAa/dMdQ61I2GmUh60AXf5Nh3TPQArzsbTHxQVqp0fAAZkzEGEfV
Z+PNu4NEB6tigyHc2OaJNwi7nepiwl3OhcLcSCcMpCNhtzDnJkTcHlwuFjUrLqbm
pM4fm+n+lvpJHfsq37y/RtpS+DeKhKvkc+ZGVrW0PeFj6wKO12eWxNwobWu2DKML
24cprfnsc7ZVo0AyE4THLzN0BNxCABGc+/xiNbOCwzwogOgJqJfHwS49J/pPm/zC
NFhBXKHicC4vgq29vY7dIxIl3olwYHVIkICsVXRRb0mef2NBizyx9cNrS1pQpk04
6ILCBMgw/QabwQQntmM6XvVNAMPRAoHBAPYALn2JYSQWXofKGGwDuLQz30qnf9vt
ORVBEmFPTrTeqynZDGwBflMu7udfouRtxksHTtijrkUGl70Yo+ndKVRkbfCvCJWu
rj5xY3YqoRjEsfy8PUIVdLPFOB9dkvGyxj8Q/jUaxrHOcDj/CFzeYXnHUJEiTenI
GEniXJGEhuJND26bDYHfqvNZdVbpMxjdqzna4iUBBaFuI1kVLIk1x66cx+qImhrz
SAcRgll5jGJTBjcwFLOCsVD8rrj3HFdEfQKBwQDGUczciC7PmJAQGeDTpNE/3LAp
07sm7usAFxfR0bubNLFOr7VsHFRTtLtV2vd4zTi0LjqMY4A7ZJy0K83dUAsF0gB6
347D5ingnNhAtxEJ9Djf9u2THhjUk/qN7oKcD8GIJoSdT66KF9VVVEzGCqxN7DNR
aA9Lki4EV/4V9QBGXI6tCSzn39U2ek692iEi11i0cpOUrzTM4rjQTwtdlwgSGRc0
xRVJAEhWE7hFTjv4vNWr2W3CSswBGlNNRlBJO3UCgcEAutf+d6kNmCxJVlfAXuIg
uvYfJgO80F0Im0UWYcSr4iKx3JIXpj0oJqQiHqh7/4YFM110nIUNyy2ruJtrfChX
yNqSylkXayEHBTR4N/s+6qITEgQjfvru7c/gxan7/worGyGcAte4jLpgcFn8jxT0
8lrZrbJhfSxWjl+YsYn4LRAcpYStKLSqkjSjNATho4RSFhpS44o4LZmKzZGQhxLK
/KvmCBQDAG9BiOTanXz9jHzE3svtHT8p0Hprdt9xrjK9AoHASunTbOq0ZA7JPIvJ
9aiosmr20JX+eDJ/6sTOZp/HMlWxNHwDGBeLcyMuMLxKBwPei5F65FUht03GM/jo
BtWZlFVDgSa5kx56azJULf35Hb13ToLEM3KHBqXvW3XhOHprLLcAGTxkPh3KpDT3
20dk1vqGWBXq0S0i3NkwTbMCq5GDA7IXmG9g5vdEj0q6gaK/C0rMnLmiRFLQZT+2
l1/Z2JxJu9FGvRCyQnGoheWLmeYbAJNddqsybKg5F1OcOE2RAoHAY2dQKRBqhaPc
UZAgdoaMg47V/6p1/bX4MbCWbBgdW+2kLkeNnMIeLKhtSxClwlNGipqEkdf89cwD
zrk9XAHSJ5l7eYlPoRLjBV3uEPaM5s5e2jJWbW/rMrR1e5RJ2C2eTRlZLuQLvJXf
32Vnod3GK5n0duif+lcdyvlYqc6bMFxCiroF5+IVRSW86WjBixo3zOGqRS6U9YFH
HmR/wMG3qCFYGKmg7eAndb9lgWvkHVq3ft/YKMY2IRnIbtrKntqE
-----END RSA PRIVATE KEY-----
+168
View File
@@ -0,0 +1,168 @@
import NbdClient from '../index.mjs'
import { spawn, exec } from 'node:child_process'
import fs from 'node:fs/promises'
import { test } from 'tap'
import tmp from 'tmp'
import { pFromCallback } from 'promise-toolbox'
import { Socket } from 'node:net'
import { NBD_DEFAULT_PORT } from '../constants.mjs'
import assert from 'node:assert'
const FILE_SIZE = 10 * 1024 * 1024
async function createTempFile(size) {
const tmpPath = await pFromCallback(cb => tmp.file(cb))
const data = Buffer.alloc(size, 0)
for (let i = 0; i < size; i += 4) {
data.writeUInt32BE(i, i)
}
await fs.writeFile(tmpPath, data)
return tmpPath
}
async function spawnNbdKit(path) {
let tries = 5
// wait for server to be ready
const nbdServer = spawn(
'nbdkit',
[
'file',
path,
'--newstyle', //
'--exit-with-parent',
'--read-only',
'--export-name=MY_SECRET_EXPORT',
'--tls=on',
'--tls-certificates=./tests/',
// '--tls-verify-peer',
// '--verbose',
'--exit-with-parent',
],
{
stdio: ['inherit', 'inherit', 'inherit'],
}
)
nbdServer.on('error', err => {
console.error(err)
})
do {
try {
const socket = new Socket()
await new Promise((resolve, reject) => {
socket.connect(NBD_DEFAULT_PORT, 'localhost')
socket.once('error', reject)
socket.once('connect', resolve)
})
socket.destroy()
break
} catch (err) {
tries--
if (tries <= 0) {
throw err
} else {
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
} while (true)
return nbdServer
}
async function killNbdKit() {
return new Promise((resolve, reject) =>
exec('pkill -9 -f -o nbdkit', err => {
err ? reject(err) : resolve()
})
)
}
test('it works with unsecured network', async tap => {
const path = await createTempFile(FILE_SIZE)
let nbdServer = await spawnNbdKit(path)
const client = new NbdClient(
{
address: '127.0.0.1',
exportname: 'MY_SECRET_EXPORT',
cert: `-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUeHpQ0IeD6BmP2zgsv3LV3J4BI/EwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMzA1MTcxMzU1MzBaFw0yNDA1
MTYxMzU1MzBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQC/8wLopj/iZY6ijmpvgCJsl+zY0hQZQcIoaCs0H75u
8PPSzHedtOLURAkJeMmIS40UY/eIvHh7yZolevaSJLNT2Iolscvc2W9NCF4N1V6y
zs4pDzP+YPF7Q8ldNaQIX0bAk4PfaMSM+pLh67u+uI40732AfQqD01BNCTD/uHRB
lKnQuqQpe9UM9UzRRVejpu1r19D4dJruAm6y2SJVTeT4a1sSJixl6I1YPmt80FJh
gq9O2KRGbXp1xIjemWgW99MHg63pTgxEiULwdJOGgmqGRDzgZKJS5UUpxe/ViEO4
59I18vIkgibaRYhENgmnP3lIzTOLlUe07tbSML5RGBbBAgMBAAGjUzBRMB0GA1Ud
DgQWBBR/8+zYoL0H0LdWfULHg1LynFdSbzAfBgNVHSMEGDAWgBR/8+zYoL0H0LdW
fULHg1LynFdSbzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBD
OF5bTmbDEGoZ6OuQaI0vyya/T4FeaoWmh22gLeL6dEEmUVGJ1NyMTOvG9GiGJ8OM
QhD1uHJei45/bXOYIDGey2+LwLWye7T4vtRFhf8amYh0ReyP/NV4/JoR/U3pTSH6
tns7GZ4YWdwUhvOOlm17EQKVO/hP3t9mp74gcjdL4bCe5MYSheKuNACAakC1OR0U
ZakJMP9ijvQuq8spfCzrK+NbHKNHR9tEgQw+ax/t1Au4dGVtFbcoxqCrx2kTl0RP
CYu1Xn/FVPx1HoRgWc7E8wFhDcA/P3SJtfIQWHB9FzSaBflKGR4t8WCE2eE8+cTB
57ABhfYpMlZ4aHjuN1bL
-----END CERTIFICATE-----
`,
},
{
readAhead: 2,
}
)
await client.connect()
tap.equal(client.exportSize, BigInt(FILE_SIZE))
const CHUNK_SIZE = 1024 * 1024 // non default size
const indexes = []
for (let i = 0; i < FILE_SIZE / CHUNK_SIZE; i++) {
indexes.push(i)
}
const nbdIterator = client.readBlocks(function* () {
for (const index of indexes) {
yield { index, size: CHUNK_SIZE }
}
})
let i = 0
for await (const block of nbdIterator) {
let blockOk = true
let firstFail
for (let j = 0; j < CHUNK_SIZE; j += 4) {
const wanted = i * CHUNK_SIZE + j
const found = block.readUInt32BE(j)
blockOk = blockOk && found === wanted
if (!blockOk && firstFail === undefined) {
firstFail = j
}
}
tap.ok(blockOk, `check block ${i} content`)
i++
// flaky server is flaky
if (i % 7 === 0) {
// kill the older nbdkit process
await killNbdKit()
nbdServer = await spawnNbdKit(path)
}
}
// we can reuse the conneciton to read other blocks
// default iterator
const nbdIteratorWithDefaultBlockIterator = client.readBlocks()
let nb = 0
for await (const block of nbdIteratorWithDefaultBlockIterator) {
nb++
tap.equal(block.length, 2 * 1024 * 1024)
}
tap.equal(nb, 5)
assert.rejects(() => client.readBlock(100, CHUNK_SIZE))
await client.disconnect()
// double disconnection shouldn't pose any problem
await client.disconnect()
nbdServer.kill()
await fs.unlink(path)
})
+21
View File
@@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUeHpQ0IeD6BmP2zgsv3LV3J4BI/EwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMzA1MTcxMzU1MzBaFw0yNDA1
MTYxMzU1MzBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQC/8wLopj/iZY6ijmpvgCJsl+zY0hQZQcIoaCs0H75u
8PPSzHedtOLURAkJeMmIS40UY/eIvHh7yZolevaSJLNT2Iolscvc2W9NCF4N1V6y
zs4pDzP+YPF7Q8ldNaQIX0bAk4PfaMSM+pLh67u+uI40732AfQqD01BNCTD/uHRB
lKnQuqQpe9UM9UzRRVejpu1r19D4dJruAm6y2SJVTeT4a1sSJixl6I1YPmt80FJh
gq9O2KRGbXp1xIjemWgW99MHg63pTgxEiULwdJOGgmqGRDzgZKJS5UUpxe/ViEO4
59I18vIkgibaRYhENgmnP3lIzTOLlUe07tbSML5RGBbBAgMBAAGjUzBRMB0GA1Ud
DgQWBBR/8+zYoL0H0LdWfULHg1LynFdSbzAfBgNVHSMEGDAWgBR/8+zYoL0H0LdW
fULHg1LynFdSbzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBD
OF5bTmbDEGoZ6OuQaI0vyya/T4FeaoWmh22gLeL6dEEmUVGJ1NyMTOvG9GiGJ8OM
QhD1uHJei45/bXOYIDGey2+LwLWye7T4vtRFhf8amYh0ReyP/NV4/JoR/U3pTSH6
tns7GZ4YWdwUhvOOlm17EQKVO/hP3t9mp74gcjdL4bCe5MYSheKuNACAakC1OR0U
ZakJMP9ijvQuq8spfCzrK+NbHKNHR9tEgQw+ax/t1Au4dGVtFbcoxqCrx2kTl0RP
CYu1Xn/FVPx1HoRgWc7E8wFhDcA/P3SJtfIQWHB9FzSaBflKGR4t8WCE2eE8+cTB
57ABhfYpMlZ4aHjuN1bL
-----END CERTIFICATE-----
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC/8wLopj/iZY6i
jmpvgCJsl+zY0hQZQcIoaCs0H75u8PPSzHedtOLURAkJeMmIS40UY/eIvHh7yZol
evaSJLNT2Iolscvc2W9NCF4N1V6yzs4pDzP+YPF7Q8ldNaQIX0bAk4PfaMSM+pLh
67u+uI40732AfQqD01BNCTD/uHRBlKnQuqQpe9UM9UzRRVejpu1r19D4dJruAm6y
2SJVTeT4a1sSJixl6I1YPmt80FJhgq9O2KRGbXp1xIjemWgW99MHg63pTgxEiULw
dJOGgmqGRDzgZKJS5UUpxe/ViEO459I18vIkgibaRYhENgmnP3lIzTOLlUe07tbS
ML5RGBbBAgMBAAECggEATLYiafcTHfgnZmjTOad0WoDnC4n9tVBV948WARlUooLS
duL3RQRHCLz9/ZaTuFA1XDpNcYyc/B/IZoU7aJGZR3+JSmJBjowpUphu+klVNNG4
i6lDRrzYlUI0hfdLjHsDTDBIKi91KcB0lix/VkvsrVQvDHwsiR2ZAIiVWAWQFKrR
5O3DhSTHbqyq47uR58rWr4Zf3zvZaUl841AS1yELzCiZqz7AenvyWphim0c0XA5d
I63CEShntHnEAA9OMcP8+BNf/3AmqB4welY+m8elB3aJNH+j7DKq/AWqaM5nl2PC
cS6qgpxwOyTxEOyj1xhwK5ZMRR3heW3NfutIxSOPlwKBgQDB9ZkrBeeGVtCISO7C
eCANzSLpeVrahTvaCSQLdPHsLRLDUc+5mxdpi3CaRlzYs3S1OWdAtyWX9mBryltF
qDPhCNjFDyHok4D3wLEWdS9oUVwEKUM8fOPW3tXLLiMM7p4862Qo7LqnqHzPqsnz
22iZo5yjcc7aLJ+VmFrbAowwOwKBgQD9WNCvczTd7Ymn7zEvdiAyNoS0OZ0orwEJ
zGaxtjqVguGklNfrb/UB+eKNGE80+YnMiSaFc9IQPetLntZdV0L7kWYdCI8kGDNA
DbVRCOp+z8DwAojlrb/zsYu23anQozT3WeHxVU66lNuyEQvSW2tJa8gN1htrD7uY
5KLibYrBMwKBgEM0iiHyJcrSgeb2/mO7o7+keJhVSDm3OInP6QFfQAQJihrLWiKB
rpcPjbCm+LzNUX8JqNEvpIMHB1nR/9Ye9frfSdzd5W3kzicKSVHywL5wkmWOtpFa
5Mcq5wFDtzlf5MxO86GKhRJauwRptRgdyhySKFApuva1x4XaCIEiXNjJAoGBAN82
t3c+HCBEv3o05rMYcrmLC1T3Rh6oQlPtwbVmByvfywsFEVCgrc/16MPD3VWhXuXV
GRmPuE8THxLbead30M5xhvShq+xzXgRbj5s8Lc9ZIHbW5OLoOS1vCtgtaQcoJOyi
Rs4pCVqe+QpktnO6lEZ2Libys+maTQEiwNibBxu9AoGAUG1V5aKMoXa7pmGeuFR6
ES+1NDiCt6yDq9BsLZ+e2uqvWTkvTGLLwvH6xf9a0pnnILd0AUTKAAaoUdZS6++E
cGob7fxMwEE+UETp0QBgLtfjtExMOFwr2avw8PV4CYEUkPUAm2OFB2Twh+d/PNfr
FAxF1rN47SBPNbFI8N4TFsg=
-----END PRIVATE KEY-----
+1
View File
@@ -0,0 +1 @@
../../scripts/npmignore
+22
View File
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2015 reedog117
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+127
View File
@@ -0,0 +1,127 @@
forked from https://github.com/reedog117/node-vsphere-soap
# node-vsphere-soap
[![Join the chat at https://gitter.im/reedog117/node-vsphere-soap](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/reedog117/node-vsphere-soap?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
This is a Node.js module to connect to VMware vCenter servers and/or ESXi hosts and perform operations using the [vSphere Web Services API]. If you're feeling really adventurous, you can use this module to port vSphere operations from other languages (such as the Perl, Python, and Go libraries that exist) and have fully native Node.js code controlling your VMware virtual infrastructure!
This is very much in alpha.
## Authors
- Patrick C - [@reedog117]
## Version
0.0.2-5
## Installation
```sh
$ npm install node-vsphere-soap --save
```
## Sample Code
### To connect to a vCenter server:
var nvs = require('node-vsphere-soap');
var vc = new nvs.Client(host, user, password, sslVerify);
vc.once('ready', function() {
// perform work here
});
vc.once('error', function(err) {
// handle error here
});
#### Arguments
- host = hostname or IP of vCenter/ESX/ESXi server
- user = username
- password = password
- sslVerify = true|false - set to false if you have self-signed/unverified certificates
#### Events
- ready = emits when session authenticated with server
- error = emits when there's an error
- _err_ contains the error
#### Client instance variables
- serviceContent - ServiceContent object retrieved by RetrieveServiceContent API call
- userName - username of authenticated user
- fullName - full name of authenticated user
### To run a command:
var vcCmd = vc.runCommand( commandToRun, arguments );
vcCmd.once('result', function( result, raw, soapHeader) {
// handle results
});
vcCmd.once('error', function( err) {
// handle errors
});
#### Arguments
- commandToRun = Method from the vSphere API
- arguments = JSON document containing arguments to send
#### Events
- result = emits when session authenticated with server
- _result_ contains the JSON-formatted result from the server
- _raw_ contains the raw SOAP XML response from the server
- _soapHeader_ contains any soapHeaders from the server
- error = emits when there's an error
- _err_ contains the error
Make sure you check out tests/vsphere-soap.test.js for examples on how to create commands to run
## Development
node-vsphere-soap uses a number of open source projects to work properly:
- [node.js] - evented I/O for the backend
- [node-soap] - SOAP client for Node.js
- [soap-cookie] - cookie authentication for the node-soap module
- [lodash] - for quickly manipulating JSON
- [lab] - testing engine
- [code] - assertion engine used with lab
Want to contribute? Great!
### Todo's
- Write More Tests
- Create Travis CI test harness with a fake vCenter Instance
- Add Code Comments
### Testing
I have been testing on a Mac with node v0.10.36 and both ESXi and vCenter 5.5.
To edit tests, edit the file **test/vsphere-soap.test.js**
To point the module at your own vCenter/ESXi host, edit **config-test.stub.js** and save it as **config-test.js**
To run test scripts:
```sh
$ npm test
```
## License
MIT
[vSphere Web Services API]: http://pubs.vmware.com/vsphere-55/topic/com.vmware.wssdk.apiref.doc/right-pane.html
[node-soap]: https://github.com/vpulim/node-soap
[node.js]: http://nodejs.org/
[soap-cookie]: https://github.com/shanestillwell/soap-cookie
[code]: https://github.com/hapijs/code
[lab]: https://github.com/hapijs/lab
[lodash]: https://lodash.com/
[@reedog117]: http://www.twitter.com/reedog117
+230
View File
@@ -0,0 +1,230 @@
/*
node-vsphere-soap
client.js
This file creates the Client class
- when the class is instantiated, a connection will be made to the ESXi/vCenter server to verify that the creds are good
- upon a bad login, the connnection will be terminated
*/
import { EventEmitter } from 'events'
import axios from 'axios'
import https from 'node:https'
import util from 'util'
import soap from 'soap'
import Cookie from 'soap-cookie' // required for session persistence
// Client class
// inherits from EventEmitter
// possible events: connect, error, ready
export function Client(vCenterHostname, username, password, sslVerify) {
this.status = 'disconnected'
this.reconnectCount = 0
sslVerify = typeof sslVerify !== 'undefined' ? sslVerify : false
EventEmitter.call(this)
// sslVerify argument handling
if (sslVerify) {
this.clientopts = {}
} else {
this.clientopts = {
request: axios.create({
httpsAgent: new https.Agent({
rejectUnauthorized: false,
}),
}),
}
}
this.connectionInfo = {
host: vCenterHostname,
user: username,
password,
sslVerify,
}
this._loginArgs = {
userName: this.connectionInfo.user,
password: this.connectionInfo.password,
}
this._vcUrl = 'https://' + this.connectionInfo.host + '/sdk/vimService.wsdl'
// connect to the vCenter / ESXi host
this.on('connect', this._connect)
this.emit('connect')
// close session
this.on('close', this._close)
return this
}
util.inherits(Client, EventEmitter)
Client.prototype.runCommand = function (command, args) {
const self = this
let cmdargs
if (!args || args === null) {
cmdargs = {}
} else {
cmdargs = args
}
const emitter = new EventEmitter()
// check if client has successfully connected
if (self.status === 'ready' || self.status === 'connecting') {
self.client.VimService.VimPort[command](cmdargs, function (err, result, raw, soapHeader) {
if (err) {
_soapErrorHandler(self, emitter, command, cmdargs, err)
}
if (command === 'Logout') {
self.status = 'disconnected'
process.removeAllListeners('beforeExit')
}
emitter.emit('result', result, raw, soapHeader)
})
} else {
// if connection not ready or connecting, reconnect to instance
if (self.status === 'disconnected') {
self.emit('connect')
}
self.once('ready', function () {
self.client.VimService.VimPort[command](cmdargs, function (err, result, raw, soapHeader) {
if (err) {
_soapErrorHandler(self, emitter, command, cmdargs, err)
}
if (command === 'Logout') {
self.status = 'disconnected'
process.removeAllListeners('beforeExit')
}
emitter.emit('result', result, raw, soapHeader)
})
})
}
return emitter
}
Client.prototype.close = function () {
const self = this
self.emit('close')
}
Client.prototype._connect = function () {
const self = this
if (self.status !== 'disconnected') {
return
}
self.status = 'connecting'
soap.createClient(
self._vcUrl,
self.clientopts,
function (err, client) {
if (err) {
self.emit('error', err)
throw err
}
self.client = client // save client for later use
self
.runCommand('RetrieveServiceContent', { _this: 'ServiceInstance' })
.once('result', function (result, raw, soapHeader) {
if (!result.returnval) {
self.status = 'disconnected'
self.emit('error', raw)
return
}
self.serviceContent = result.returnval
self.sessionManager = result.returnval.sessionManager
const loginArgs = { _this: self.sessionManager, ...self._loginArgs }
self
.runCommand('Login', loginArgs)
.once('result', function (result, raw, soapHeader) {
self.authCookie = new Cookie(client.lastResponseHeaders)
self.client.setSecurity(self.authCookie) // needed since vSphere SOAP WS uses cookies
self.userName = result.returnval.userName
self.fullName = result.returnval.fullName
self.reconnectCount = 0
self.status = 'ready'
self.emit('ready')
process.once('beforeExit', self._close)
})
.once('error', function (err) {
self.status = 'disconnected'
self.emit('error', err)
})
})
.once('error', function (err) {
self.status = 'disconnected'
self.emit('error', err)
})
},
self._vcUrl
)
}
Client.prototype._close = function () {
const self = this
if (self.status === 'ready') {
self
.runCommand('Logout', { _this: self.sessionManager })
.once('result', function () {
self.status = 'disconnected'
})
.once('error', function () {
/* don't care of error during disconnection */
self.status = 'disconnected'
})
} else {
self.status = 'disconnected'
}
}
function _soapErrorHandler(self, emitter, command, args, err) {
err = err || { body: 'general error' }
if (err.body.match(/session is not authenticated/)) {
self.status = 'disconnected'
process.removeAllListeners('beforeExit')
if (self.reconnectCount < 10) {
self.reconnectCount += 1
self
.runCommand(command, args)
.once('result', function (result, raw, soapHeader) {
emitter.emit('result', result, raw, soapHeader)
})
.once('error', function (err) {
emitter.emit('error', err.body)
throw err
})
} else {
emitter.emit('error', err.body)
throw err
}
} else {
emitter.emit('error', err.body)
throw err
}
}
// end
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@vates/node-vsphere-soap",
"version": "1.0.0",
"description": "interface to vSphere SOAP/WSDL from node for interfacing with vCenter or ESXi, forked from node-vsphere-soap",
"main": "lib/client.mjs",
"author": "reedog117",
"repository": {
"directory": "@vates/node-vsphere-soap",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"axios": "^1.4.0",
"soap": "^1.0.0",
"soap-cookie": "^0.10.1"
},
"devDependencies": {
"test": "^3.3.0"
},
"keywords": [
"vsphere",
"vcenter",
"api",
"soap",
"wsdl"
],
"preferGlobal": false,
"license": "MIT",
"private": false,
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/node-vsphere-soap",
"engines": {
"node": ">=14"
},
"scripts": {
"postversion": "npm publish --access public"
}
}
@@ -0,0 +1,11 @@
// place your own credentials here for a vCenter or ESXi server
// this information will be used for connecting to a vCenter instance
// for module testing
// name the file config-test.js
export const vCenterTestCreds = {
vCenterIP: 'vcsa',
vCenterUser: 'vcuser',
vCenterPassword: 'vcpw',
vCenter: true,
}
@@ -0,0 +1,138 @@
/*
vsphere-soap.test.js
tests for the vCenterConnectionInstance class
*/
import assert from 'assert'
import { describe, it } from 'test'
import * as vc from '../lib/client.mjs'
// eslint-disable-next-line n/no-missing-import
import { vCenterTestCreds as TestCreds } from '../config-test.mjs'
const VItest = new vc.Client(TestCreds.vCenterIP, TestCreds.vCenterUser, TestCreds.vCenterPassword, false)
describe('Client object initialization:', function () {
it('provides a successful login', { timeout: 5000 }, function (t, done) {
VItest.once('ready', function () {
assert.notEqual(VItest.userName, null)
assert.notEqual(VItest.fullName, null)
assert.notEqual(VItest.serviceContent, null)
done()
}).once('error', function (err) {
console.error(err)
// this should fail if there's a problem
assert.notEqual(VItest.userName, null)
assert.notEqual(VItest.fullName, null)
assert.notEqual(VItest.serviceContent, null)
done()
})
})
})
describe('Client reconnection test:', function () {
it('can successfully reconnect', { timeout: 5000 }, function (t, done) {
VItest.runCommand('Logout', { _this: VItest.serviceContent.sessionManager })
.once('result', function (result) {
// now we're logged out, so let's try running a command to test automatic re-login
VItest.runCommand('CurrentTime', { _this: 'ServiceInstance' })
.once('result', function (result) {
assert(result.returnval instanceof Date)
done()
})
.once('error', function (err) {
console.error(err)
})
})
.once('error', function (err) {
console.error(err)
})
})
})
// these tests don't work yet
describe('Client tests - query commands:', function () {
it('retrieves current time', { timeout: 5000 }, function (t, done) {
VItest.runCommand('CurrentTime', { _this: 'ServiceInstance' }).once('result', function (result) {
assert(result.returnval instanceof Date)
done()
})
})
it('retrieves current time 2 (check for event clobbering)', { timeout: 5000 }, function (t, done) {
VItest.runCommand('CurrentTime', { _this: 'ServiceInstance' }).once('result', function (result) {
assert(result.returnval instanceof Date)
done()
})
})
it('can obtain the names of all Virtual Machines in the inventory', { timeout: 20000 }, function (t, done) {
// get property collector
const propertyCollector = VItest.serviceContent.propertyCollector
// get view manager
const viewManager = VItest.serviceContent.viewManager
// get root folder
const rootFolder = VItest.serviceContent.rootFolder
let containerView, objectSpec, traversalSpec, propertySpec, propertyFilterSpec
// this is the equivalent to
VItest.runCommand('CreateContainerView', {
_this: viewManager,
container: rootFolder,
type: ['VirtualMachine'],
recursive: true,
}).once('result', function (result) {
// build all the data structures needed to query all the vm names
containerView = result.returnval
objectSpec = {
attributes: { 'xsi:type': 'ObjectSpec' }, // setting attributes xsi:type is important or else the server may mis-recognize types!
obj: containerView,
skip: true,
}
traversalSpec = {
attributes: { 'xsi:type': 'TraversalSpec' },
name: 'traverseEntities',
type: 'ContainerView',
path: 'view',
skip: false,
}
objectSpec = { ...objectSpec, selectSet: [traversalSpec] }
propertySpec = {
attributes: { 'xsi:type': 'PropertySpec' },
type: 'VirtualMachine',
pathSet: ['name'],
}
propertyFilterSpec = {
attributes: { 'xsi:type': 'PropertyFilterSpec' },
propSet: [propertySpec],
objectSet: [objectSpec],
}
// TODO: research why it fails if propSet is declared after objectSet
VItest.runCommand('RetrievePropertiesEx', {
_this: propertyCollector,
specSet: [propertyFilterSpec],
options: { attributes: { type: 'RetrieveOptions' } },
})
.once('result', function (result, raw) {
assert.notEqual(result.returnval.objects, null)
if (Array.isArray(result.returnval.objects)) {
assert.strictEqual(result.returnval.objects[0].obj.attributes.type, 'VirtualMachine')
} else {
assert.strictEqual(result.returnval.objects.obj.attributes.type, 'VirtualMachine')
}
done()
})
.once('error', function (err) {
console.error('\n\nlast request : ' + VItest.client.lastRequest, err)
})
})
})
})
+8
View File
@@ -1,6 +1,7 @@
'use strict' 'use strict'
const assert = require('assert') const assert = require('assert')
const isUtf8 = require('isutf8')
/** /**
* Read a chunk of data from a stream. * Read a chunk of data from a stream.
@@ -81,6 +82,13 @@ exports.readChunkStrict = async function readChunkStrict(stream, size) {
if (size !== undefined && chunk.length !== size) { if (size !== undefined && chunk.length !== size) {
const error = new Error(`stream has ended with not enough data (actual: ${chunk.length}, expected: ${size})`) const error = new Error(`stream has ended with not enough data (actual: ${chunk.length}, expected: ${size})`)
// Buffer.isUtf8 is too recent for now
// @todo : replace external package by Buffer.isUtf8 when the supported version of node reach 18
if (chunk.length < 1024 && isUtf8(chunk)) {
error.text = chunk.toString('utf8')
}
Object.defineProperties(error, { Object.defineProperties(error, {
chunk: { chunk: {
value: chunk, value: chunk,
+41 -1
View File
@@ -102,12 +102,37 @@ describe('readChunkStrict', function () {
assert.strictEqual(error.chunk, undefined) assert.strictEqual(error.chunk, undefined)
}) })
it('throws if stream ends with not enough data', async () => { it('throws if stream ends with not enough data, utf8', async () => {
const error = await rejectionOf(readChunkStrict(makeStream(['foo', 'bar']), 10)) const error = await rejectionOf(readChunkStrict(makeStream(['foo', 'bar']), 10))
assert(error instanceof Error) assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 6, expected: 10)') assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 6, expected: 10)')
assert.strictEqual(error.text, 'foobar')
assert.deepEqual(error.chunk, Buffer.from('foobar')) assert.deepEqual(error.chunk, Buffer.from('foobar'))
}) })
it('throws if stream ends with not enough data, non utf8 ', async () => {
const source = [Buffer.alloc(10, 128), Buffer.alloc(10, 128)]
const error = await rejectionOf(readChunkStrict(makeStream(source), 30))
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 20, expected: 30)')
assert.strictEqual(error.text, undefined)
assert.deepEqual(error.chunk, Buffer.concat(source))
})
it('throws if stream ends with not enough data, utf8 , long data', async () => {
const source = Buffer.from('a'.repeat(1500))
const error = await rejectionOf(readChunkStrict(makeStream([source]), 2000))
assert(error instanceof Error)
assert.strictEqual(error.message, `stream has ended with not enough data (actual: 1500, expected: 2000)`)
assert.strictEqual(error.text, undefined)
assert.deepEqual(error.chunk, source)
})
it('succeed', async () => {
const source = Buffer.from('a'.repeat(20))
const chunk = await readChunkStrict(makeStream([source]), 10)
assert.deepEqual(source.subarray(10), chunk)
})
}) })
describe('skip', function () { describe('skip', function () {
@@ -134,6 +159,16 @@ describe('skip', function () {
it('returns less size if stream ends', async () => { it('returns less size if stream ends', async () => {
assert.deepEqual(await skip(makeStream('foo bar'), 10), 7) assert.deepEqual(await skip(makeStream('foo bar'), 10), 7)
}) })
it('put back if it read too much', async () => {
let source = makeStream(['foo', 'bar'])
await skip(source, 1) // read part of data chunk
const chunk = (await readChunkStrict(source, 2)).toString('utf-8')
assert.strictEqual(chunk, 'oo')
source = makeStream(['foo', 'bar'])
assert.strictEqual(await skip(source, 3), 3) // read aligned with data chunk
})
}) })
describe('skipStrict', function () { describe('skipStrict', function () {
@@ -144,4 +179,9 @@ describe('skipStrict', function () {
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 7, expected: 10)') assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 7, expected: 10)')
assert.deepEqual(error.bytesSkipped, 7) assert.deepEqual(error.bytesSkipped, 7)
}) })
it('succeed', async () => {
const source = makeStream(['foo', 'bar', 'baz'])
const res = await skipStrict(source, 4)
assert.strictEqual(res, undefined)
})
}) })
+3
View File
@@ -33,5 +33,8 @@
}, },
"devDependencies": { "devDependencies": {
"test": "^3.2.1" "test": "^3.2.1"
},
"dependencies": {
"isutf8": "^4.0.0"
} }
} }
+29 -6
View File
@@ -2,10 +2,8 @@
import { Task } from '@vates/task' import { Task } from '@vates/task'
const task = new Task({ const task = new Task({
// data in this object will be sent along the *start* event // this object will be sent in the *start* event
// properties: {
// property names should be chosen as not to clash with properties used by `Task` or `combineEvents`
data: {
name: 'my task', name: 'my task',
}, },
@@ -16,13 +14,15 @@ const task = new Task({
// this function is called each time this task or one of it's subtasks change state // this function is called each time this task or one of it's subtasks change state
const { id, timestamp, type } = event const { id, timestamp, type } = event
if (type === 'start') { if (type === 'start') {
const { name, parentId } = event const { name, parentId, properties } = event
} else if (type === 'end') { } else if (type === 'end') {
const { result, status } = event const { result, status } = event
} else if (type === 'info' || type === 'warning') { } else if (type === 'info' || type === 'warning') {
const { data, message } = event const { data, message } = event
} else if (type === 'property') { } else if (type === 'property') {
const { name, value } = event const { name, value } = event
} else if (type === 'abortionRequested') {
const { reason } = event
} }
}, },
}) })
@@ -36,7 +36,6 @@ task.id
// - pending // - pending
// - success // - success
// - failure // - failure
// - aborted
task.status task.status
// Triggers the abort signal associated to the task. // Triggers the abort signal associated to the task.
@@ -89,6 +88,30 @@ const onProgress = makeOnProgress({
onRootTaskStart(taskLog) { onRootTaskStart(taskLog) {
// `taskLog` is an object reflecting the state of this task and all its subtasks, // `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. // and will be mutated in real-time to reflect the changes of the task.
// timestamp at which the task started
taskLog.start
// current status of the task as described in the previous section
taskLog.status
// undefined or a dictionary of properties attached to the task
taskLog.properties
// timestamp at which the abortion was requested, undefined otherwise
taskLog.abortionRequestedAt
// undefined or an array of infos emitted on the task
taskLog.infos
// undefined or an array of warnings emitted on the task
taskLog.warnings
// timestamp at which the task ended, undefined otherwise
taskLog.end
// undefined or the result value of the task
taskLog.result
}, },
// This function is called each time a root task ends. // This function is called each time a root task ends.
+29 -6
View File
@@ -18,10 +18,8 @@ npm install --save @vates/task
import { Task } from '@vates/task' import { Task } from '@vates/task'
const task = new Task({ const task = new Task({
// data in this object will be sent along the *start* event // this object will be sent in the *start* event
// properties: {
// property names should be chosen as not to clash with properties used by `Task` or `combineEvents`
data: {
name: 'my task', name: 'my task',
}, },
@@ -32,13 +30,15 @@ const task = new Task({
// this function is called each time this task or one of it's subtasks change state // this function is called each time this task or one of it's subtasks change state
const { id, timestamp, type } = event const { id, timestamp, type } = event
if (type === 'start') { if (type === 'start') {
const { name, parentId } = event const { name, parentId, properties } = event
} else if (type === 'end') { } else if (type === 'end') {
const { result, status } = event const { result, status } = event
} else if (type === 'info' || type === 'warning') { } else if (type === 'info' || type === 'warning') {
const { data, message } = event const { data, message } = event
} else if (type === 'property') { } else if (type === 'property') {
const { name, value } = event const { name, value } = event
} else if (type === 'abortionRequested') {
const { reason } = event
} }
}, },
}) })
@@ -52,7 +52,6 @@ task.id
// - pending // - pending
// - success // - success
// - failure // - failure
// - aborted
task.status task.status
// Triggers the abort signal associated to the task. // Triggers the abort signal associated to the task.
@@ -105,6 +104,30 @@ const onProgress = makeOnProgress({
onRootTaskStart(taskLog) { onRootTaskStart(taskLog) {
// `taskLog` is an object reflecting the state of this task and all its subtasks, // `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. // and will be mutated in real-time to reflect the changes of the task.
// timestamp at which the task started
taskLog.start
// current status of the task as described in the previous section
taskLog.status
// undefined or a dictionnary of properties attached to the task
taskLog.properties
// timestamp at which the abortion was requested, undefined otherwise
taskLog.abortionRequestedAt
// undefined or an array of infos emitted on the task
taskLog.infos
// undefined or an array of warnings emitted on the task
taskLog.warnings
// timestamp at which the task ended, undefined otherwise
taskLog.end
// undefined or the result value of the task
taskLog.result
}, },
// This function is called each time a root task ends. // This function is called each time a root task ends.
+8 -24
View File
@@ -4,36 +4,18 @@ const assert = require('node:assert').strict
const noop = Function.prototype 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 }) { exports.makeOnProgress = function ({ onRootTaskEnd = noop, onRootTaskStart = noop, onTaskUpdate = noop }) {
const taskLogs = new Map() const taskLogs = new Map()
return function onProgress(event) { return function onProgress(event) {
const { id, type } = event const { id, type } = event
let taskLog let taskLog
if (type === 'start') { if (type === 'start') {
taskLog = omit(event, IGNORED_START_PROPS) taskLog = {
taskLog.start = event.timestamp id,
taskLog.status = 'pending' properties: { __proto__: null, ...event.properties },
start: event.timestamp,
status: 'pending',
}
taskLogs.set(id, taskLog) taskLogs.set(id, taskLog)
const { parentId } = event const { parentId } = event
@@ -65,6 +47,8 @@ exports.makeOnProgress = function ({ onRootTaskEnd = noop, onRootTaskStart = noo
taskLog.end = event.timestamp taskLog.end = event.timestamp
taskLog.result = event.result taskLog.result = event.result
taskLog.status = event.status taskLog.status = event.status
} else if (type === 'abortionRequested') {
taskLog.abortionRequestedAt = event.timestamp
} }
if (type === 'end' && taskLog.$root === taskLog) { if (type === 'end' && taskLog.$root === taskLog) {
+28 -14
View File
@@ -11,7 +11,7 @@ describe('makeOnProgress()', function () {
const events = [] const events = []
let log let log
const task = new Task({ const task = new Task({
data: { name: 'task' }, properties: { name: 'task' },
onProgress: makeOnProgress({ onProgress: makeOnProgress({
onRootTaskStart(log_) { onRootTaskStart(log_) {
assert.equal(log, undefined) assert.equal(log, undefined)
@@ -32,36 +32,50 @@ describe('makeOnProgress()', function () {
assert.equal(events.length, 0) assert.equal(events.length, 0)
let i = 0
await task.run(async () => { await task.run(async () => {
assert.equal(events[0], 'onRootTaskStart') assert.equal(events[i++], 'onRootTaskStart')
assert.equal(events[1], 'onTaskUpdate') assert.equal(events[i++], 'onTaskUpdate')
assert.equal(log.name, 'task') assert.equal(log.id, task.id)
assert.equal(log.properties.name, 'task')
assert(Math.abs(log.start - Date.now()) < 10)
Task.set('name', 'new name')
assert.equal(events[i++], 'onTaskUpdate')
assert.equal(log.properties.name, 'new name')
Task.set('progress', 0) Task.set('progress', 0)
assert.equal(events[2], 'onTaskUpdate') assert.equal(events[i++], 'onTaskUpdate')
assert.equal(log.properties.progress, 0) assert.equal(log.properties.progress, 0)
Task.info('foo', {}) Task.info('foo', {})
assert.equal(events[3], 'onTaskUpdate') assert.equal(events[i++], 'onTaskUpdate')
assert.deepEqual(log.infos, [{ data: {}, message: 'foo' }]) assert.deepEqual(log.infos, [{ data: {}, message: 'foo' }])
await Task.run({ data: { name: 'subtask' } }, () => { const subtask = new Task({ properties: { name: 'subtask' } })
assert.equal(events[4], 'onTaskUpdate') await subtask.run(() => {
assert.equal(log.tasks[0].name, 'subtask') assert.equal(events[i++], 'onTaskUpdate')
assert.equal(log.tasks[0].properties.name, 'subtask')
Task.warning('bar', {}) Task.warning('bar', {})
assert.equal(events[5], 'onTaskUpdate') assert.equal(events[i++], 'onTaskUpdate')
assert.deepEqual(log.tasks[0].warnings, [{ data: {}, message: 'bar' }]) assert.deepEqual(log.tasks[0].warnings, [{ data: {}, message: 'bar' }])
subtask.abort()
assert.equal(events[i++], 'onTaskUpdate')
assert(Math.abs(log.tasks[0].abortionRequestedAt - Date.now()) < 10)
}) })
assert.equal(events[6], 'onTaskUpdate') assert.equal(events[i++], 'onTaskUpdate')
assert.equal(log.tasks[0].status, 'success') assert.equal(log.tasks[0].status, 'success')
Task.set('progress', 100) Task.set('progress', 100)
assert.equal(events[7], 'onTaskUpdate') assert.equal(events[i++], 'onTaskUpdate')
assert.equal(log.properties.progress, 100) assert.equal(log.properties.progress, 100)
}) })
assert.equal(events[8], 'onRootTaskEnd') assert.equal(events[i++], 'onRootTaskEnd')
assert.equal(events[9], 'onTaskUpdate') assert.equal(events[i++], 'onTaskUpdate')
assert(Math.abs(log.end - Date.now()) < 10)
assert.equal(log.status, 'success') assert.equal(log.status, 'success')
}) })
}) })
+12 -11
View File
@@ -10,11 +10,10 @@ function define(object, property, value) {
const noop = Function.prototype const noop = Function.prototype
const ABORTED = 'aborted'
const FAILURE = 'failure' const FAILURE = 'failure'
const PENDING = 'pending' const PENDING = 'pending'
const SUCCESS = 'success' const SUCCESS = 'success'
exports.STATUS = { ABORTED, FAILURE, PENDING, SUCCESS } exports.STATUS = { FAILURE, PENDING, SUCCESS }
// stored in the global context so that various versions of the library can interact. // stored in the global context so that various versions of the library can interact.
const asyncStorageKey = '@vates/task@0' const asyncStorageKey = '@vates/task@0'
@@ -83,8 +82,8 @@ exports.Task = class Task {
return this.#status return this.#status
} }
constructor({ data = {}, onProgress } = {}) { constructor({ properties, onProgress } = {}) {
this.#startData = data this.#startData = { properties }
if (onProgress !== undefined) { if (onProgress !== undefined) {
this.#onProgress = onProgress this.#onProgress = onProgress
@@ -105,12 +104,16 @@ exports.Task = class Task {
const { signal } = this.#abortController const { signal } = this.#abortController
signal.addEventListener('abort', () => { signal.addEventListener('abort', () => {
if (this.status === PENDING && !this.#running) { if (this.status === PENDING) {
this.#maybeStart() this.#maybeStart()
const status = ABORTED this.#emit('abortionRequested', { reason: signal.reason })
this.#status = status
this.#emit('end', { result: signal.reason, status }) if (!this.#running) {
const status = FAILURE
this.#status = status
this.#emit('end', { result: signal.reason, status })
}
} }
}) })
} }
@@ -156,9 +159,7 @@ exports.Task = class Task {
this.#running = false this.#running = false
return result return result
} catch (result) { } catch (result) {
const { signal } = this.#abortController const status = FAILURE
const aborted = signal.aborted && result === signal.reason
const status = aborted ? ABORTED : FAILURE
this.#status = status this.#status = status
this.#emit('end', { status, result }) this.#emit('end', { status, result })
+26 -20
View File
@@ -15,7 +15,7 @@ function assertEvent(task, expected, eventIndex = -1) {
assert.equal(typeof actual.id, 'string') assert.equal(typeof actual.id, 'string')
assert.equal(typeof actual.timestamp, 'number') assert.equal(typeof actual.timestamp, 'number')
for (const keys of Object.keys(expected)) { for (const keys of Object.keys(expected)) {
assert.equal(actual[keys], expected[keys]) assert.deepEqual(actual[keys], expected[keys])
} }
} }
@@ -30,10 +30,10 @@ function createTask(opts) {
describe('Task', function () { describe('Task', function () {
describe('contructor', function () { describe('contructor', function () {
it('data properties are passed to the start event', async function () { it('data properties are passed to the start event', async function () {
const data = { foo: 0, bar: 1 } const properties = { foo: 0, bar: 1 }
const task = createTask({ data }) const task = createTask({ properties })
await task.run(noop) await task.run(noop)
assertEvent(task, { ...data, type: 'start' }, 0) assertEvent(task, { type: 'start', properties }, 0)
}) })
}) })
@@ -79,20 +79,22 @@ describe('Task', function () {
}) })
.catch(noop) .catch(noop)
assert.equal(task.status, 'aborted') assert.equal(task.status, 'failure')
assert.equal(task.$events.length, 2) assert.equal(task.$events.length, 3)
assertEvent(task, { type: 'start' }, 0) assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'aborted', result: reason }, 1) assertEvent(task, { type: 'abortionRequested', reason }, 1)
assertEvent(task, { type: 'end', status: 'failure', result: reason }, 2)
}) })
it('does not abort if the task fails without the abort reason', async function () { it('does not abort if the task fails without the abort reason', async function () {
const task = createTask() const task = createTask()
const reason = {}
const result = new Error() const result = new Error()
await task await task
.run(() => { .run(() => {
task.abort({}) task.abort(reason)
throw result throw result
}) })
@@ -100,18 +102,20 @@ describe('Task', function () {
assert.equal(task.status, 'failure') assert.equal(task.status, 'failure')
assert.equal(task.$events.length, 2) assert.equal(task.$events.length, 3)
assertEvent(task, { type: 'start' }, 0) assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'failure', result }, 1) assertEvent(task, { type: 'abortionRequested', reason }, 1)
assertEvent(task, { type: 'end', status: 'failure', result }, 2)
}) })
it('does not abort if the task succeed', async function () { it('does not abort if the task succeed', async function () {
const task = createTask() const task = createTask()
const reason = {}
const result = {} const result = {}
await task await task
.run(() => { .run(() => {
task.abort({}) task.abort(reason)
return result return result
}) })
@@ -119,9 +123,10 @@ describe('Task', function () {
assert.equal(task.status, 'success') assert.equal(task.status, 'success')
assert.equal(task.$events.length, 2) assert.equal(task.$events.length, 3)
assertEvent(task, { type: 'start' }, 0) assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'success', result }, 1) assertEvent(task, { type: 'abortionRequested', reason }, 1)
assertEvent(task, { type: 'end', status: 'success', result }, 2)
}) })
it('aborts before task is running', function () { it('aborts before task is running', function () {
@@ -130,11 +135,12 @@ describe('Task', function () {
task.abort(reason) task.abort(reason)
assert.equal(task.status, 'aborted') assert.equal(task.status, 'failure')
assert.equal(task.$events.length, 2) assert.equal(task.$events.length, 3)
assertEvent(task, { type: 'start' }, 0) assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'aborted', result: reason }, 1) assertEvent(task, { type: 'abortionRequested', reason }, 1)
assertEvent(task, { type: 'end', status: 'failure', result: reason }, 2)
}) })
}) })
@@ -243,7 +249,7 @@ describe('Task', function () {
assert.equal(task.status, 'failure') assert.equal(task.status, 'failure')
}) })
it('changes to aborted after run is complete', async function () { it('changes to failure if aborted after run is complete', async function () {
const task = createTask() const task = createTask()
await task await task
.run(() => { .run(() => {
@@ -252,13 +258,13 @@ describe('Task', function () {
Task.abortSignal.throwIfAborted() Task.abortSignal.throwIfAborted()
}) })
.catch(noop) .catch(noop)
assert.equal(task.status, 'aborted') assert.equal(task.status, 'failure')
}) })
it('changes to aborted if aborted when not running', async function () { it('changes to failure if aborted when not running', function () {
const task = createTask() const task = createTask()
task.abort() task.abort()
assert.equal(task.status, 'aborted') assert.equal(task.status, 'failure')
}) })
}) })
+1 -1
View File
@@ -13,7 +13,7 @@
"url": "https://vates.fr" "url": "https://vates.fr"
}, },
"license": "ISC", "license": "ISC",
"version": "0.1.2", "version": "0.2.0",
"engines": { "engines": {
"node": ">=14" "node": ">=14"
}, },
@@ -1,5 +1,5 @@
import { asyncMap } from '@xen-orchestra/async-map' import { asyncMap } from '@xen-orchestra/async-map'
import { RemoteAdapter } from '@xen-orchestra/backups/RemoteAdapter.js' import { RemoteAdapter } from '@xen-orchestra/backups/RemoteAdapter.mjs'
import { getSyncedHandler } from '@xen-orchestra/fs' import { getSyncedHandler } from '@xen-orchestra/fs'
import getopts from 'getopts' import getopts from 'getopts'
import { basename, dirname } from 'path' import { basename, dirname } from 'path'
+3 -3
View File
@@ -7,8 +7,8 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues", "bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": { "dependencies": {
"@xen-orchestra/async-map": "^0.1.2", "@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.36.0", "@xen-orchestra/backups": "^0.39.0",
"@xen-orchestra/fs": "^3.3.4", "@xen-orchestra/fs": "^4.0.1",
"filenamify": "^4.1.0", "filenamify": "^4.1.0",
"getopts": "^2.2.5", "getopts": "^2.2.5",
"lodash": "^4.17.15", "lodash": "^4.17.15",
@@ -27,7 +27,7 @@
"scripts": { "scripts": {
"postversion": "npm publish --access public" "postversion": "npm publish --access public"
}, },
"version": "1.0.6", "version": "1.0.9",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"author": { "author": {
"name": "Vates SAS", "name": "Vates SAS",
-307
View File
@@ -1,307 +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,
validateVhdStreams: 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 => {
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, () =>
new VmBackup({
baseSettings,
config,
getSnapshotNameLabel,
healthCheckSr,
job,
remoteAdapters,
schedule,
settings: { ...settings, ...allSettings[vm.uuid] },
srs,
throttleStream,
vm,
}).run()
)
}),
error =>
runTask(taskStart, () => {
throw error
})
)
}
const { concurrency } = settings
await asyncMapSettled(vmIds, concurrency === 0 ? handleVm : limitConcurrency(concurrency)(handleVm))
}
)
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Metadata } from './_runners/Metadata.mjs'
import { VmsRemote } from './_runners/VmsRemote.mjs'
import { VmsXapi } from './_runners/VmsXapi.mjs'
export function createRunner(opts) {
const { type } = opts.job
switch (type) {
case 'backup':
return new VmsXapi(opts)
case 'mirrorBackup':
return new VmsRemote(opts)
case 'metadataBackup':
return new Metadata(opts)
default:
throw new Error(`No runner for the backup type ${type}`)
}
}
@@ -1,8 +1,6 @@
'use strict' import { asyncMap } from '@xen-orchestra/async-map'
const { asyncMap } = require('@xen-orchestra/async-map') export class DurablePartition {
exports.DurablePartition = class DurablePartition {
// private resource API is used exceptionally to be able to separate resource creation and release // private resource API is used exceptionally to be able to separate resource creation and release
#partitionDisposers = {} #partitionDisposers = {}
@@ -1,8 +1,6 @@
'use strict' import { Task } from './Task.mjs'
const { Task } = require('./Task') export class HealthCheckVmBackup {
exports.HealthCheckVmBackup = class HealthCheckVmBackup {
#restoredVm #restoredVm
#timeout #timeout
#xapi #xapi
@@ -1,16 +1,14 @@
'use strict' import assert from 'node:assert'
const assert = require('assert') import { formatFilenameDate } from './_filenameDate.mjs'
import { importIncrementalVm } from './_incrementalVm.mjs'
import { Task } from './Task.mjs'
import { watchStreamSize } from './_watchStreamSize.mjs'
const { formatFilenameDate } = require('./_filenameDate.js') export class ImportVmBackup {
const { importDeltaVm } = require('./_deltaVm.js')
const { Task } = require('./Task.js')
const { watchStreamSize } = require('./_watchStreamSize.js')
exports.ImportVmBackup = class ImportVmBackup {
constructor({ adapter, metadata, srUuid, xapi, settings: { newMacAddresses, mapVdisSrs = {} } = {} }) { constructor({ adapter, metadata, srUuid, xapi, settings: { newMacAddresses, mapVdisSrs = {} } = {} }) {
this._adapter = adapter this._adapter = adapter
this._importDeltaVmSettings = { newMacAddresses, mapVdisSrs } this._importIncrementalVmSettings = { newMacAddresses, mapVdisSrs }
this._metadata = metadata this._metadata = metadata
this._srUuid = srUuid this._srUuid = srUuid
this._xapi = xapi this._xapi = xapi
@@ -31,11 +29,11 @@ exports.ImportVmBackup = class ImportVmBackup {
assert.strictEqual(metadata.mode, 'delta') assert.strictEqual(metadata.mode, 'delta')
const ignoredVdis = new Set( const ignoredVdis = new Set(
Object.entries(this._importDeltaVmSettings.mapVdisSrs) Object.entries(this._importIncrementalVmSettings.mapVdisSrs)
.filter(([_, srUuid]) => srUuid === null) .filter(([_, srUuid]) => srUuid === null)
.map(([vdiUuid]) => vdiUuid) .map(([vdiUuid]) => vdiUuid)
) )
backup = await adapter.readDeltaVmBackup(metadata, ignoredVdis) backup = await adapter.readIncrementalVmBackup(metadata, ignoredVdis)
Object.values(backup.streams).forEach(stream => watchStreamSize(stream, sizeContainer)) Object.values(backup.streams).forEach(stream => watchStreamSize(stream, sizeContainer))
} }
@@ -49,8 +47,8 @@ exports.ImportVmBackup = class ImportVmBackup {
const vmRef = isFull const vmRef = isFull
? await xapi.VM_import(backup, srRef) ? await xapi.VM_import(backup, srRef)
: await importDeltaVm(backup, await xapi.getRecord('SR', srRef), { : await importIncrementalVm(backup, await xapi.getRecord('SR', srRef), {
...this._importDeltaVmSettings, ...this._importIncrementalVmSettings,
detectBase: false, detectBase: false,
}) })
@@ -1,43 +1,38 @@
'use strict' import { asyncEach } from '@vates/async-each'
import { asyncMap, asyncMapSettled } from '@xen-orchestra/async-map'
import { compose } from '@vates/compose'
import { createLogger } from '@xen-orchestra/log'
import { createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdDirectory, VhdSynthetic } from 'vhd-lib'
import { decorateMethodsWith } from '@vates/decorate-with'
import { deduped } from '@vates/disposable/deduped.js'
import { dirname, join, normalize, resolve } from 'node:path'
import { execFile } from 'child_process'
import { mount } from '@vates/fuse-vhd'
import { readdir, lstat } from 'node:fs/promises'
import { synchronized } from 'decorator-synchronized'
import { v4 as uuidv4 } from 'uuid'
import { ZipFile } from 'yazl'
import Disposable from 'promise-toolbox/Disposable'
import fromCallback from 'promise-toolbox/fromCallback'
import fromEvent from 'promise-toolbox/fromEvent'
import groupBy from 'lodash/groupBy.js'
import pDefer from 'promise-toolbox/defer'
import pickBy from 'lodash/pickBy.js'
import zlib from 'zlib'
const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map') import { BACKUP_DIR } from './_getVmBackupDir.mjs'
const { synchronized } = require('decorator-synchronized') import { cleanVm } from './_cleanVm.mjs'
const Disposable = require('promise-toolbox/Disposable') import { formatFilenameDate } from './_filenameDate.mjs'
const fromCallback = require('promise-toolbox/fromCallback') import { getTmpDir } from './_getTmpDir.mjs'
const fromEvent = require('promise-toolbox/fromEvent') import { isMetadataFile } from './_backupType.mjs'
const pDefer = require('promise-toolbox/defer') import { isValidXva } from './_isValidXva.mjs'
const groupBy = require('lodash/groupBy.js') import { listPartitions, LVM_PARTITION_TYPE } from './_listPartitions.mjs'
const pickBy = require('lodash/pickBy.js') import { lvs, pvs } from './_lvm.mjs'
const { dirname, join, normalize, resolve } = require('path') import { watchStreamSize } from './_watchStreamSize.mjs'
const { createLogger } = require('@xen-orchestra/log')
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')
const { execFile } = require('child_process')
const { readdir, lstat } = require('fs-extra')
const { v4: uuidv4 } = require('uuid')
const { ZipFile } = require('yazl')
const zlib = require('zlib')
const { BACKUP_DIR } = require('./_getVmBackupDir.js') export const DIR_XO_CONFIG_BACKUPS = 'xo-config-backups'
const { cleanVm } = require('./_cleanVm.js')
const { formatFilenameDate } = require('./_filenameDate.js')
const { getTmpDir } = require('./_getTmpDir.js')
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')
// @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 DIR_XO_CONFIG_BACKUPS = 'xo-config-backups' export const DIR_XO_POOL_METADATA_BACKUPS = 'xo-pool-metadata-backups'
exports.DIR_XO_CONFIG_BACKUPS = DIR_XO_CONFIG_BACKUPS
const DIR_XO_POOL_METADATA_BACKUPS = 'xo-pool-metadata-backups'
exports.DIR_XO_POOL_METADATA_BACKUPS = DIR_XO_POOL_METADATA_BACKUPS
const { debug, warn } = createLogger('xo:backups:RemoteAdapter') const { debug, warn } = createLogger('xo:backups:RemoteAdapter')
@@ -76,7 +71,7 @@ const debounceResourceFactory = factory =>
return this._debounceResource(factory.apply(this, arguments)) return this._debounceResource(factory.apply(this, arguments))
} }
class RemoteAdapter { export class RemoteAdapter {
constructor( constructor(
handler, handler,
{ debounceResource = res => res, dirMode, vhdDirectoryCompression, useGetDiskLegacy = false } = {} { debounceResource = res => res, dirMode, vhdDirectoryCompression, useGetDiskLegacy = false } = {}
@@ -333,7 +328,7 @@ class RemoteAdapter {
const RE_VHDI = /^vhdi(\d+)$/ const RE_VHDI = /^vhdi(\d+)$/
const handler = this._handler const handler = this._handler
const diskPath = handler._getFilePath('/' + diskId) const diskPath = handler.getFilePath('/' + diskId)
const mountDir = yield getTmpDir() const mountDir = yield getTmpDir()
await fromCallback(execFile, 'vhdimount', [diskPath, mountDir]) await fromCallback(execFile, 'vhdimount', [diskPath, mountDir])
try { try {
@@ -404,20 +399,27 @@ class RemoteAdapter {
return `${baseName}.vhd` return `${baseName}.vhd`
} }
async listAllVmBackups() { async listAllVms() {
const handler = this._handler const handler = this._handler
const vmsUuids = []
const backups = { __proto__: null } await asyncEach(await handler.list(BACKUP_DIR), async entry => {
await asyncMap(await handler.list(BACKUP_DIR), async entry => {
// ignore hidden and lock files // ignore hidden and lock files
if (entry[0] !== '.' && !entry.endsWith('.lock')) { if (entry[0] !== '.' && !entry.endsWith('.lock')) {
const vmBackups = await this.listVmBackups(entry) vmsUuids.push(entry)
if (vmBackups.length !== 0) {
backups[entry] = vmBackups
}
} }
}) })
return vmsUuids
}
async listAllVmBackups() {
const vmsUuids = await this.listAllVms()
const backups = { __proto__: null }
await asyncEach(vmsUuids, async vmUuid => {
const vmBackups = await this.listVmBackups(vmUuid)
if (vmBackups.length !== 0) {
backups[vmUuid] = vmBackups
}
})
return backups return backups
} }
@@ -691,8 +693,8 @@ class RemoteAdapter {
} }
// open the hierarchy of ancestors until we find a full one // open the hierarchy of ancestors until we find a full one
async _createSyntheticStream(handler, path) { async _createVhdStream(handler, path, { useChain }) {
const disposableSynthetic = await VhdSynthetic.fromVhdChain(handler, path) const disposableSynthetic = useChain ? await VhdSynthetic.fromVhdChain(handler, path) : await openVhd(handler, path)
// I don't want the vhds to be disposed on return // I don't want the vhds to be disposed on return
// but only when the stream is done ( or failed ) // but only when the stream is done ( or failed )
@@ -717,7 +719,7 @@ class RemoteAdapter {
return stream return stream
} }
async readDeltaVmBackup(metadata, ignoredVdis) { async readIncrementalVmBackup(metadata, ignoredVdis, { useChain = true } = {}) {
const handler = this._handler const handler = this._handler
const { vbds, vhds, vifs, vm, vmSnapshot } = metadata const { vbds, vhds, vifs, vm, vmSnapshot } = metadata
const dir = dirname(metadata._filename) const dir = dirname(metadata._filename)
@@ -725,7 +727,7 @@ class RemoteAdapter {
const streams = {} const streams = {}
await asyncMapSettled(Object.keys(vdis), async ref => { await asyncMapSettled(Object.keys(vdis), async ref => {
streams[`${ref}.vhd`] = await this._createSyntheticStream(handler, join(dir, vhds[ref])) streams[`${ref}.vhd`] = await this._createVhdStream(handler, join(dir, vhds[ref]), { useChain })
}) })
return { return {
@@ -828,5 +830,3 @@ decorateMethodsWith(RemoteAdapter, {
getPartition: Disposable.factory, getPartition: Disposable.factory,
}) })
exports.RemoteAdapter = RemoteAdapter
@@ -1,9 +1,9 @@
'use strict' import { join, resolve } from 'node:path/posix'
const { DIR_XO_POOL_METADATA_BACKUPS } = require('./RemoteAdapter.js') import { DIR_XO_POOL_METADATA_BACKUPS } from './RemoteAdapter.mjs'
const { PATH_DB_DUMP } = require('./_PoolMetadataBackup.js') import { PATH_DB_DUMP } from './_runners/_PoolMetadataBackup.mjs'
exports.RestoreMetadataBackup = class RestoreMetadataBackup { export class RestoreMetadataBackup {
constructor({ backupId, handler, xapi }) { constructor({ backupId, handler, xapi }) {
this._backupId = backupId this._backupId = backupId
this._handler = handler this._handler = handler
@@ -20,7 +20,8 @@ exports.RestoreMetadataBackup = class RestoreMetadataBackup {
task: xapi.task_create('Import pool metadata'), task: xapi.task_create('Import pool metadata'),
}) })
} else { } else {
return String(await handler.readFile(`${backupId}/data.json`)) const metadata = JSON.parse(await handler.readFile(join(backupId, 'metadata.json')))
return String(await handler.readFile(resolve(backupId, metadata.data ?? 'data.json')))
} }
} }
} }
@@ -1,7 +1,5 @@
'use strict' import CancelToken from 'promise-toolbox/CancelToken'
import Zone from 'node-zone'
const CancelToken = require('promise-toolbox/CancelToken')
const Zone = require('node-zone')
const logAfterEnd = log => { const logAfterEnd = log => {
const error = new Error('task has already ended') const error = new Error('task has already ended')
@@ -30,7 +28,7 @@ const serializeError = error =>
const $$task = Symbol('@xen-orchestra/backups/Task') const $$task = Symbol('@xen-orchestra/backups/Task')
class Task { export class Task {
static get cancelToken() { static get cancelToken() {
const task = Zone.current.data[$$task] const task = Zone.current.data[$$task]
return task !== undefined ? task.#cancelToken : CancelToken.none return task !== undefined ? task.#cancelToken : CancelToken.none
@@ -151,7 +149,6 @@ class Task {
}) })
} }
} }
exports.Task = Task
for (const method of ['info', 'warning']) { for (const method of ['info', 'warning']) {
Task[method] = (...args) => Zone.current.data[$$task]?.[method](...args) Task[method] = (...args) => Zone.current.data[$$task]?.[method](...args)
-515
View File
@@ -1,515 +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 vhdStreamValidator = require('vhd-lib/vhdStreamValidator.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 { pipeline } = require('node:stream')
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),
},
})
const noop = Function.prototype
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,
})
// 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 _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,
})
-6
View File
@@ -1,6 +0,0 @@
'use strict'
exports.isMetadataFile = filename => filename.endsWith('.json')
exports.isVhdFile = filename => filename.endsWith('.vhd')
exports.isXvaFile = filename => filename.endsWith('.xva')
exports.isXvaSumFile = filename => filename.endsWith('.xva.checksum')
+4
View File
@@ -0,0 +1,4 @@
export const isMetadataFile = filename => filename.endsWith('.json')
export const isVhdFile = filename => filename.endsWith('.vhd')
export const isXvaFile = filename => filename.endsWith('.xva')
export const isXvaSumFile = filename => filename.endsWith('.xva.checksum')
@@ -1,25 +1,25 @@
'use strict' import { createLogger } from '@xen-orchestra/log'
import { catchGlobalErrors } from '@xen-orchestra/log/configure'
const logger = require('@xen-orchestra/log').createLogger('xo:backups:worker') import Disposable from 'promise-toolbox/Disposable'
import ignoreErrors from 'promise-toolbox/ignoreErrors'
import { compose } from '@vates/compose'
import { createCachedLookup } from '@vates/cached-dns.lookup'
import { createDebounceResource } from '@vates/disposable/debounceResource.js'
import { createRunner } from './Backup.mjs'
import { decorateMethodsWith } from '@vates/decorate-with'
import { deduped } from '@vates/disposable/deduped.js'
import { getHandler } from '@xen-orchestra/fs'
import { parseDuration } from '@vates/parse-duration'
import { Xapi } from '@xen-orchestra/xapi'
require('@xen-orchestra/log/configure').catchGlobalErrors(logger) import { RemoteAdapter } from './RemoteAdapter.mjs'
import { Task } from './Task.mjs'
require('@vates/cached-dns.lookup').createCachedLookup().patchGlobal() createCachedLookup().patchGlobal()
const Disposable = require('promise-toolbox/Disposable')
const ignoreErrors = require('promise-toolbox/ignoreErrors')
const { compose } = require('@vates/compose')
const { createDebounceResource } = require('@vates/disposable/debounceResource.js')
const { decorateMethodsWith } = require('@vates/decorate-with')
const { deduped } = require('@vates/disposable/deduped.js')
const { getHandler } = require('@xen-orchestra/fs')
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')
const logger = createLogger('xo:backups:worker')
catchGlobalErrors(logger)
const { debug } = logger const { debug } = logger
class BackupWorker { class BackupWorker {
@@ -48,7 +48,7 @@ class BackupWorker {
} }
run() { run() {
return new Backup({ return createRunner({
config: this.#config, config: this.#config,
getAdapter: remoteId => this.getAdapter(this.#remotes[remoteId]), getAdapter: remoteId => this.getAdapter(this.#remotes[remoteId]),
getConnectedRecord: Disposable.factory(async function* getConnectedRecord(type, uuid) { getConnectedRecord: Disposable.factory(async function* getConnectedRecord(type, uuid) {
@@ -1,13 +1,11 @@
'use strict' import cancelable from 'promise-toolbox/cancelable'
import CancelToken from 'promise-toolbox/CancelToken'
const cancelable = require('promise-toolbox/cancelable')
const CancelToken = require('promise-toolbox/CancelToken')
// Similar to `Promise.all` + `map` but pass a cancel token to the callback // Similar to `Promise.all` + `map` but pass a cancel token to the callback
// //
// If any of the executions fails, the cancel token will be triggered and the // If any of the executions fails, the cancel token will be triggered and the
// first reason will be rejected. // first reason will be rejected.
exports.cancelableMap = cancelable(async function cancelableMap($cancelToken, iterable, callback) { export const cancelableMap = cancelable(async function cancelableMap($cancelToken, iterable, callback) {
const { cancel, token } = CancelToken.source([$cancelToken]) const { cancel, token } = CancelToken.source([$cancelToken])
try { try {
return await Promise.all( return await Promise.all(
@@ -1,19 +1,19 @@
'use strict' import test from 'test'
import { strict as assert } from 'node:assert'
const { beforeEach, afterEach, test, describe } = require('test') import tmp from 'tmp'
const assert = require('assert').strict import fs from 'fs-extra'
import * as uuid from 'uuid'
import { getHandler } from '@xen-orchestra/fs'
import { pFromCallback } from 'promise-toolbox'
import { RemoteAdapter } from './RemoteAdapter.mjs'
import { VHDFOOTER, VHDHEADER } from './tests.fixtures.mjs'
import { VhdFile, Constants, VhdDirectory, VhdAbstract } from 'vhd-lib'
import { checkAliases } from './_cleanVm.mjs'
import { dirname, basename } from 'node:path'
import { rimraf } from 'rimraf'
const rimraf = require('rimraf') const { beforeEach, afterEach, describe } = test
const tmp = require('tmp')
const fs = require('fs-extra')
const uuid = require('uuid')
const { getHandler } = require('@xen-orchestra/fs')
const { pFromCallback } = require('promise-toolbox')
const { RemoteAdapter } = require('./RemoteAdapter')
const { VHDFOOTER, VHDHEADER } = require('./tests.fixtures.js')
const { VhdFile, Constants, VhdDirectory, VhdAbstract } = require('vhd-lib')
const { checkAliases } = require('./_cleanVm')
const { dirname, basename } = require('path')
let tempDir, adapter, handler, jobId, vdiId, basePath, relativePath let tempDir, adapter, handler, jobId, vdiId, basePath, relativePath
const rootPath = 'xo-vm-backups/VMUUID/' const rootPath = 'xo-vm-backups/VMUUID/'
@@ -1,19 +1,18 @@
'use strict' import * as UUID from 'uuid'
import sum from 'lodash/sum.js'
import { asyncMap } from '@xen-orchestra/async-map'
import { Constants, openVhd, VhdAbstract, VhdFile } from 'vhd-lib'
import { isVhdAlias, resolveVhdAlias } from 'vhd-lib/aliases.js'
import { dirname, resolve } from 'node:path'
import { isMetadataFile, isVhdFile, isXvaFile, isXvaSumFile } from './_backupType.mjs'
import { limitConcurrency } from 'limit-concurrency-decorator'
import { mergeVhdChain } from 'vhd-lib/merge.js'
import { Task } from './Task.mjs'
import { Disposable } from 'promise-toolbox'
import handlerPath from '@xen-orchestra/fs/path'
const sum = require('lodash/sum')
const UUID = require('uuid')
const { asyncMap } = require('@xen-orchestra/async-map')
const { Constants, openVhd, VhdAbstract, VhdFile } = require('vhd-lib')
const { isVhdAlias, resolveVhdAlias } = require('vhd-lib/aliases')
const { dirname, resolve } = require('path')
const { DISK_TYPES } = Constants const { DISK_TYPES } = Constants
const { isMetadataFile, isVhdFile, isXvaFile, isXvaSumFile } = require('./_backupType.js')
const { limitConcurrency } = require('limit-concurrency-decorator')
const { mergeVhdChain } = require('vhd-lib/merge')
const { Task } = require('./Task.js')
const { Disposable } = require('promise-toolbox')
const handlerPath = require('@xen-orchestra/fs/path')
// checking the size of a vhd directory is costly // checking the size of a vhd directory is costly
// 1 Http Query per 1000 blocks // 1 Http Query per 1000 blocks
@@ -117,7 +116,7 @@ const listVhds = async (handler, vmDir, logWarn) => {
return { vhds, interruptedVhds, aliases } return { vhds, interruptedVhds, aliases }
} }
async function checkAliases( export async function checkAliases(
aliasPaths, aliasPaths,
targetDataRepository, targetDataRepository,
{ handler, logInfo = noop, logWarn = console.warn, remove = false } { handler, logInfo = noop, logWarn = console.warn, remove = false }
@@ -176,11 +175,9 @@ async function checkAliases(
}) })
} }
exports.checkAliases = checkAliases
const defaultMergeLimiter = limitConcurrency(1) const defaultMergeLimiter = limitConcurrency(1)
exports.cleanVm = async function cleanVm( export async function cleanVm(
vmDir, vmDir,
{ {
fixMetadata, fixMetadata,
-8
View File
@@ -1,8 +0,0 @@
'use strict'
const { utcFormat, utcParse } = require('d3-time-format')
// Format a date in ISO 8601 in a safe way to be used in filenames
// (even on Windows).
exports.formatFilenameDate = utcFormat('%Y%m%dT%H%M%SZ')
exports.parseFilenameDate = utcParse('%Y%m%dT%H%M%SZ')
+6
View File
@@ -0,0 +1,6 @@
import { utcFormat, utcParse } from 'd3-time-format'
// Format a date in ISO 8601 in a safe way to be used in filenames
// (even on Windows).
export const formatFilenameDate = utcFormat('%Y%m%dT%H%M%SZ')
export const parseFilenameDate = utcParse('%Y%m%dT%H%M%SZ')
@@ -1,6 +1,4 @@
'use strict'
// returns all entries but the last retention-th // returns all entries but the last retention-th
exports.getOldEntries = function getOldEntries(retention, entries) { export function getOldEntries(retention, entries) {
return entries === undefined ? [] : retention > 0 ? entries.slice(0, -retention) : entries return entries === undefined ? [] : retention > 0 ? entries.slice(0, -retention) : entries
} }
@@ -1,13 +1,11 @@
'use strict' import Disposable from 'promise-toolbox/Disposable'
import { join } from 'node:path'
const Disposable = require('promise-toolbox/Disposable') import { mkdir, rmdir } from 'node:fs/promises'
const { join } = require('path') import { tmpdir } from 'os'
const { mkdir, rmdir } = require('fs-extra')
const { tmpdir } = require('os')
const MAX_ATTEMPTS = 3 const MAX_ATTEMPTS = 3
exports.getTmpDir = async function getTmpDir() { export async function getTmpDir() {
for (let i = 0; true; ++i) { for (let i = 0; true; ++i) {
const path = join(tmpdir(), Math.random().toString(36).slice(2)) const path = join(tmpdir(), Math.random().toString(36).slice(2))
try { try {
@@ -1,8 +0,0 @@
'use strict'
const BACKUP_DIR = 'xo-vm-backups'
exports.BACKUP_DIR = BACKUP_DIR
exports.getVmBackupDir = function getVmBackupDir(uuid) {
return `${BACKUP_DIR}/${uuid}`
}
@@ -0,0 +1,5 @@
export const BACKUP_DIR = 'xo-vm-backups'
export function getVmBackupDir(uuid) {
return `${BACKUP_DIR}/${uuid}`
}
@@ -1,24 +1,20 @@
'use strict' import find from 'lodash/find.js'
import groupBy from 'lodash/groupBy.js'
import ignoreErrors from 'promise-toolbox/ignoreErrors'
import omit from 'lodash/omit.js'
import { asyncMap } from '@xen-orchestra/async-map'
import { CancelToken } from 'promise-toolbox'
import { compareVersions } from 'compare-versions'
import { createVhdStreamWithLength } from 'vhd-lib'
import { defer } from 'golike-defer'
const find = require('lodash/find.js') import { cancelableMap } from './_cancelableMap.mjs'
const groupBy = require('lodash/groupBy.js') import { Task } from './Task.mjs'
const ignoreErrors = require('promise-toolbox/ignoreErrors') import pick from 'lodash/pick.js'
const omit = require('lodash/omit.js')
const { asyncMap } = require('@xen-orchestra/async-map')
const { CancelToken } = require('promise-toolbox')
const { compareVersions } = require('compare-versions')
const { createVhdStreamWithLength } = require('vhd-lib')
const { defer } = require('golike-defer')
const { cancelableMap } = require('./_cancelableMap.js') export const TAG_BASE_DELTA = 'xo:base_delta'
const { Task } = require('./Task.js')
const pick = require('lodash/pick.js')
const TAG_BASE_DELTA = 'xo:base_delta' export const TAG_COPY_SRC = 'xo:copy_of'
exports.TAG_BASE_DELTA = TAG_BASE_DELTA
const TAG_COPY_SRC = 'xo:copy_of'
exports.TAG_COPY_SRC = TAG_COPY_SRC
const ensureArray = value => (value === undefined ? [] : Array.isArray(value) ? value : [value]) const ensureArray = value => (value === undefined ? [] : Array.isArray(value) ? value : [value])
const resolveUuid = async (xapi, cache, uuid, type) => { const resolveUuid = async (xapi, cache, uuid, type) => {
@@ -33,7 +29,7 @@ const resolveUuid = async (xapi, cache, uuid, type) => {
return ref return ref
} }
exports.exportDeltaVm = async function exportDeltaVm( export async function exportIncrementalVm(
vm, vm,
baseVm, baseVm,
{ {
@@ -143,18 +139,18 @@ exports.exportDeltaVm = async function exportDeltaVm(
) )
} }
exports.importDeltaVm = defer(async function importDeltaVm( export const importIncrementalVm = defer(async function importIncrementalVm(
$defer, $defer,
deltaVm, incrementalVm,
sr, sr,
{ cancelToken = CancelToken.none, detectBase = true, mapVdisSrs = {}, newMacAddresses = false } = {} { cancelToken = CancelToken.none, detectBase = true, mapVdisSrs = {}, newMacAddresses = false } = {}
) { ) {
const { version } = deltaVm const { version } = incrementalVm
if (compareVersions(version, '1.0.0') < 0) { if (compareVersions(version, '1.0.0') < 0) {
throw new Error(`Unsupported delta backup version: ${version}`) throw new Error(`Unsupported delta backup version: ${version}`)
} }
const vmRecord = deltaVm.vm const vmRecord = incrementalVm.vm
const xapi = sr.$xapi const xapi = sr.$xapi
let baseVm let baseVm
@@ -183,7 +179,7 @@ exports.importDeltaVm = defer(async function importDeltaVm(
baseVdis[vbd.VDI] = vbd.$VDI baseVdis[vbd.VDI] = vbd.$VDI
} }
}) })
const vdiRecords = deltaVm.vdis const vdiRecords = incrementalVm.vdis
// 0. Create suspend_VDI // 0. Create suspend_VDI
let suspendVdi let suspendVdi
@@ -240,7 +236,7 @@ exports.importDeltaVm = defer(async function importDeltaVm(
await asyncMap(await xapi.getField('VM', vmRef, 'VBDs'), ref => ignoreErrors.call(xapi.call('VBD.destroy', ref))) await asyncMap(await xapi.getField('VM', vmRef, 'VBDs'), ref => ignoreErrors.call(xapi.call('VBD.destroy', ref)))
// 3. Create VDIs & VBDs. // 3. Create VDIs & VBDs.
const vbdRecords = deltaVm.vbds const vbdRecords = incrementalVm.vbds
const vbds = groupBy(vbdRecords, 'VDI') const vbds = groupBy(vbdRecords, 'VDI')
const newVdis = {} const newVdis = {}
await asyncMap(Object.keys(vdiRecords), async vdiRef => { await asyncMap(Object.keys(vdiRecords), async vdiRef => {
@@ -309,7 +305,7 @@ exports.importDeltaVm = defer(async function importDeltaVm(
} }
}) })
const { streams } = deltaVm const { streams } = incrementalVm
await Promise.all([ await Promise.all([
// Import VDI contents. // Import VDI contents.
@@ -326,7 +322,7 @@ exports.importDeltaVm = defer(async function importDeltaVm(
}), }),
// Create VIFs. // 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) let network = vif.$network$uuid && xapi.getObjectByUuid(vif.$network$uuid, undefined)
if (network === undefined) { if (network === undefined) {
@@ -358,8 +354,8 @@ exports.importDeltaVm = defer(async function importDeltaVm(
]) ])
await Promise.all([ await Promise.all([
deltaVm.vm.ha_always_run && xapi.setField('VM', vmRef, 'ha_always_run', true), incrementalVm.vm.ha_always_run && xapi.setField('VM', vmRef, 'ha_always_run', true),
xapi.setField('VM', vmRef, 'name_label', deltaVm.vm.name_label), xapi.setField('VM', vmRef, 'name_label', incrementalVm.vm.name_label),
]) ])
return vmRef return vmRef
@@ -1,6 +1,4 @@
'use strict' import assert from 'node:assert'
const assert = require('assert')
const COMPRESSED_MAGIC_NUMBERS = [ const COMPRESSED_MAGIC_NUMBERS = [
// https://tools.ietf.org/html/rfc1952.html#page-5 // https://tools.ietf.org/html/rfc1952.html#page-5
@@ -47,7 +45,7 @@ const isValidTar = async (handler, size, fd) => {
} }
// TODO: find an heuristic for compressed files // TODO: find an heuristic for compressed files
async function isValidXva(path) { export async function isValidXva(path) {
const handler = this._handler const handler = this._handler
// size is longer when encrypted + reading part of an encrypted file is not implemented // size is longer when encrypted + reading part of an encrypted file is not implemented
@@ -74,6 +72,5 @@ async function isValidXva(path) {
return true return true
} }
} }
exports.isValidXva = isValidXva
const noop = Function.prototype const noop = Function.prototype
@@ -1,9 +1,7 @@
'use strict' import fromCallback from 'promise-toolbox/fromCallback'
import { createLogger } from '@xen-orchestra/log'
const fromCallback = require('promise-toolbox/fromCallback') import { createParser } from 'parse-pairs'
const { createLogger } = require('@xen-orchestra/log') import { execFile } from 'child_process'
const { createParser } = require('parse-pairs')
const { execFile } = require('child_process')
const { debug } = createLogger('xo:backups:listPartitions') const { debug } = createLogger('xo:backups:listPartitions')
@@ -24,8 +22,7 @@ const IGNORED_PARTITION_TYPES = {
0x82: true, // swap 0x82: true, // swap
} }
const LVM_PARTITION_TYPE = 0x8e export const LVM_PARTITION_TYPE = 0x8e
exports.LVM_PARTITION_TYPE = LVM_PARTITION_TYPE
const parsePartxLine = createParser({ const parsePartxLine = createParser({
keyTransform: key => (key === 'UUID' ? 'id' : key.toLowerCase()), keyTransform: key => (key === 'UUID' ? 'id' : key.toLowerCase()),
@@ -33,7 +30,7 @@ const parsePartxLine = createParser({
}) })
// returns an empty array in case of a non-partitioned disk // returns an empty array in case of a non-partitioned disk
exports.listPartitions = async function listPartitions(devicePath) { export async function listPartitions(devicePath) {
const parts = await fromCallback(execFile, 'partx', [ const parts = await fromCallback(execFile, 'partx', [
'--bytes', '--bytes',
'--output=NR,START,SIZE,NAME,UUID,TYPE', '--output=NR,START,SIZE,NAME,UUID,TYPE',
@@ -1,8 +1,6 @@
'use strict' import fromCallback from 'promise-toolbox/fromCallback'
import { createParser } from 'parse-pairs'
const fromCallback = require('promise-toolbox/fromCallback') import { execFile } from 'child_process'
const { createParser } = require('parse-pairs')
const { execFile } = require('child_process')
// =================================================================== // ===================================================================
@@ -29,5 +27,5 @@ const makeFunction =
.map(Array.isArray(fields) ? parse : line => parse(line)[fields]) .map(Array.isArray(fields) ? parse : line => parse(line)[fields])
} }
exports.lvs = makeFunction('lvs') export const lvs = makeFunction('lvs')
exports.pvs = makeFunction('pvs') export const pvs = makeFunction('pvs')
@@ -0,0 +1,132 @@
import { asyncMap } from '@xen-orchestra/async-map'
import Disposable from 'promise-toolbox/Disposable'
import ignoreErrors from 'promise-toolbox/ignoreErrors'
import { extractIdsFromSimplePattern } from '../extractIdsFromSimplePattern.mjs'
import { PoolMetadataBackup } from './_PoolMetadataBackup.mjs'
import { XoMetadataBackup } from './_XoMetadataBackup.mjs'
import { DEFAULT_SETTINGS, Abstract } from './_Abstract.mjs'
import { runTask } from './_runTask.mjs'
import { getAdaptersByRemote } from './_getAdaptersByRemote.mjs'
const DEFAULT_METADATA_SETTINGS = {
retentionPoolMetadata: 0,
retentionXoMetadata: 0,
}
export const Metadata = class MetadataBackupRunner extends Abstract {
_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)
}
)
}
}
@@ -0,0 +1,96 @@
import { asyncMapSettled } from '@xen-orchestra/async-map'
import Disposable from 'promise-toolbox/Disposable'
import { limitConcurrency } from 'limit-concurrency-decorator'
import { extractIdsFromSimplePattern } from '../extractIdsFromSimplePattern.mjs'
import { Task } from '../Task.mjs'
import createStreamThrottle from './_createStreamThrottle.mjs'
import { DEFAULT_SETTINGS, Abstract } from './_Abstract.mjs'
import { runTask } from './_runTask.mjs'
import { getAdaptersByRemote } from './_getAdaptersByRemote.mjs'
import { FullRemote } from './_vmRunners/FullRemote.mjs'
import { IncrementalRemote } from './_vmRunners/IncrementalRemote.mjs'
const DEFAULT_REMOTE_VM_SETTINGS = {
concurrency: 2,
copyRetention: 0,
deleteFirst: false,
exportRetention: 0,
healthCheckSr: undefined,
healthCheckVmsWithTags: [],
maxExportRate: 0,
maxMergedDeltasPerRun: Infinity,
timeout: 0,
validateVhdStreams: false,
vmTimeout: 0,
}
export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
_computeBaseSettings(config, job) {
const baseSettings = { ...DEFAULT_SETTINGS }
Object.assign(baseSettings, DEFAULT_REMOTE_VM_SETTINGS, config.defaultSettings, config.vm?.defaultSettings)
Object.assign(baseSettings, job.settings[''])
return baseSettings
}
async run() {
const job = this._job
const schedule = this._schedule
const settings = this._settings
const throttleStream = createStreamThrottle(settings.maxExportRate)
const config = this._config
await Disposable.use(
() => this._getAdapter(job.sourceRemote),
() => (settings.healthCheckSr !== undefined ? this._getRecord('SR', settings.healthCheckSr) : undefined),
Disposable.all(
extractIdsFromSimplePattern(job.remotes).map(id => id !== job.sourceRemote && this._getAdapter(id))
),
async ({ adapter: sourceRemoteAdapter }, healthCheckSr, remoteAdapters) => {
// remove adapters that failed (already handled)
remoteAdapters = remoteAdapters.filter(_ => !!_)
if (remoteAdapters.length === 0) {
return
}
const vmsUuids = await sourceRemoteAdapter.listAllVms()
Task.info('vms', { vms: vmsUuids })
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 } }
const opts = {
baseSettings,
config,
job,
healthCheckSr,
remoteAdapters,
schedule,
settings: { ...settings, ...allSettings[vmUuid] },
sourceRemoteAdapter,
throttleStream,
vmUuid,
}
let vmBackup
if (job.mode === 'delta') {
vmBackup = new IncrementalRemote(opts)
} else if (job.mode === 'full') {
vmBackup = new FullRemote(opts)
} else {
throw new Error(`Job mode ${job.mode} not implemented for mirror backup`)
}
return runTask(taskStart, () => vmBackup.run())
}
const { concurrency } = settings
await asyncMapSettled(vmsUuids, !concurrency ? handleVm : limitConcurrency(concurrency)(handleVm))
}
)
}
}
+137
View File
@@ -0,0 +1,137 @@
import { asyncMapSettled } from '@xen-orchestra/async-map'
import Disposable from 'promise-toolbox/Disposable'
import { limitConcurrency } from 'limit-concurrency-decorator'
import { extractIdsFromSimplePattern } from '../extractIdsFromSimplePattern.mjs'
import { Task } from '../Task.mjs'
import createStreamThrottle from './_createStreamThrottle.mjs'
import { DEFAULT_SETTINGS, Abstract } from './_Abstract.mjs'
import { runTask } from './_runTask.mjs'
import { getAdaptersByRemote } from './_getAdaptersByRemote.mjs'
import { IncrementalXapi } from './_vmRunners/IncrementalXapi.mjs'
import { FullXapi } from './_vmRunners/FullXapi.mjs'
const DEFAULT_XAPI_VM_SETTINGS = {
bypassVdiChainsCheck: false,
checkpointSnapshot: false,
concurrency: 2,
copyRetention: 0,
deleteFirst: false,
diskPerVmConcurrency: 0, // not limited by default
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,
}
export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
_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 IncrementalXapi(opts)
} else {
if (job.mode === 'full') {
vmBackup = new FullXapi(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))
}
)
}
}
@@ -0,0 +1,49 @@
import Disposable from 'promise-toolbox/Disposable'
import pTimeout from 'promise-toolbox/timeout'
import { compileTemplate } from '@xen-orchestra/template'
import { runTask } from './_runTask.mjs'
import { RemoteTimeoutError } from './_RemoteTimeoutError.mjs'
export const DEFAULT_SETTINGS = {
getRemoteTimeout: 300e3,
reportWhen: 'failure',
}
export const Abstract = class AbstractRunner {
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)
)
}
}
}
}
@@ -1,16 +1,13 @@
'use strict' import { asyncMap } from '@xen-orchestra/async-map'
const { asyncMap } = require('@xen-orchestra/async-map') import { DIR_XO_POOL_METADATA_BACKUPS } from '../RemoteAdapter.mjs'
import { forkStreamUnpipe } from './_forkStreamUnpipe.mjs'
import { formatFilenameDate } from '../_filenameDate.mjs'
import { Task } from '../Task.mjs'
const { DIR_XO_POOL_METADATA_BACKUPS } = require('./RemoteAdapter.js') export const PATH_DB_DUMP = '/pool/xmldbdump'
const { forkStreamUnpipe } = require('./_forkStreamUnpipe.js')
const { formatFilenameDate } = require('./_filenameDate.js')
const { Task } = require('./Task.js')
const PATH_DB_DUMP = '/pool/xmldbdump' export class PoolMetadataBackup {
exports.PATH_DB_DUMP = PATH_DB_DUMP
exports.PoolMetadataBackup = class PoolMetadataBackup {
constructor({ config, job, pool, remoteAdapters, schedule, settings }) { constructor({ config, job, pool, remoteAdapters, schedule, settings }) {
this._config = config this._config = config
this._job = job this._job = job
@@ -0,0 +1,6 @@
export class RemoteTimeoutError extends Error {
constructor(remoteId) {
super('timeout while getting the remote ' + remoteId)
this.remoteId = remoteId
}
}
@@ -1,12 +1,11 @@
'use strict' import { asyncMap } from '@xen-orchestra/async-map'
import { join } from '@xen-orchestra/fs/path'
const { asyncMap } = require('@xen-orchestra/async-map') import { DIR_XO_CONFIG_BACKUPS } from '../RemoteAdapter.mjs'
import { formatFilenameDate } from '../_filenameDate.mjs'
import { Task } from '../Task.mjs'
const { DIR_XO_CONFIG_BACKUPS } = require('./RemoteAdapter.js') export class XoMetadataBackup {
const { formatFilenameDate } = require('./_filenameDate.js')
const { Task } = require('./Task.js')
exports.XoMetadataBackup = class XoMetadataBackup {
constructor({ config, job, remoteAdapters, schedule, settings }) { constructor({ config, job, remoteAdapters, schedule, settings }) {
this._config = config this._config = config
this._job = job this._job = job
@@ -23,10 +22,11 @@ exports.XoMetadataBackup = class XoMetadataBackup {
const dir = `${scheduleDir}/${formatFilenameDate(timestamp)}` const dir = `${scheduleDir}/${formatFilenameDate(timestamp)}`
const data = job.xoMetadata const data = job.xoMetadata
const fileName = `${dir}/data.json` const dataBaseName = './data.json'
const metadata = JSON.stringify( const metadata = JSON.stringify(
{ {
data: dataBaseName,
jobId: job.id, jobId: job.id,
jobName: job.name, jobName: job.name,
scheduleId: schedule.id, scheduleId: schedule.id,
@@ -36,6 +36,8 @@ exports.XoMetadataBackup = class XoMetadataBackup {
null, null,
2 2
) )
const dataFileName = join(dir, dataBaseName)
const metaDataFileName = `${dir}/metadata.json` const metaDataFileName = `${dir}/metadata.json`
await asyncMap( await asyncMap(
@@ -52,7 +54,7 @@ exports.XoMetadataBackup = class XoMetadataBackup {
async () => { async () => {
const handler = adapter.handler const handler = adapter.handler
const dirMode = this._config.dirMode const dirMode = this._config.dirMode
await handler.outputFile(fileName, data, { dirMode }) await handler.outputFile(dataFileName, data, { dirMode })
await handler.outputFile(metaDataFileName, metadata, { await handler.outputFile(metaDataFileName, metadata, {
dirMode, dirMode,
}) })
@@ -1,12 +1,10 @@
'use strict' import { pipeline } from 'node:stream'
import { ThrottleGroup } from '@kldzj/stream-throttle'
const { pipeline } = require('node:stream') import identity from 'lodash/identity.js'
const { ThrottleGroup } = require('@kldzj/stream-throttle')
const identity = require('lodash/identity.js')
const noop = Function.prototype const noop = Function.prototype
module.exports = function createStreamThrottle(rate) { export default function createStreamThrottle(rate) {
if (rate === 0) { if (rate === 0) {
return identity return identity
} }
@@ -1,14 +1,13 @@
'use strict' import { createLogger } from '@xen-orchestra/log'
import { finished, PassThrough } from 'node:stream'
const { finished, PassThrough } = require('node:stream') const { debug } = createLogger('xo:backups:forkStreamUnpipe')
const { debug } = require('@xen-orchestra/log').createLogger('xo:backups:forkStreamUnpipe')
// create a new readable stream from an existing one which may be piped later // create a new readable stream from an existing one which may be piped later
// //
// in case of error in the new readable stream, it will simply be unpiped // in case of error in the new readable stream, it will simply be unpiped
// from the original one // from the original one
exports.forkStreamUnpipe = function forkStreamUnpipe(source) { export function forkStreamUnpipe(source) {
const { forks = 0 } = source const { forks = 0 } = source
source.forks = forks + 1 source.forks = forks + 1
@@ -0,0 +1,7 @@
export function getAdaptersByRemote(adapters) {
const adaptersByRemote = {}
adapters.forEach(({ adapter, remoteId }) => {
adaptersByRemote[remoteId] = adapter
})
return adaptersByRemote
}
@@ -0,0 +1,5 @@
import { Task } from '../Task.mjs'
const noop = Function.prototype
export const runTask = (...args) => Task.run(...args).catch(noop) // errors are handled by logs
@@ -0,0 +1,50 @@
import { decorateMethodsWith } from '@vates/decorate-with'
import { defer } from 'golike-defer'
import { AbstractRemote } from './_AbstractRemote.mjs'
import { FullRemoteWriter } from '../_writers/FullRemoteWriter.mjs'
import { forkStreamUnpipe } from '../_forkStreamUnpipe.mjs'
import { watchStreamSize } from '../../_watchStreamSize.mjs'
import { Task } from '../../Task.mjs'
export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote {
_getRemoteWriter() {
return FullRemoteWriter
}
async _run($defer) {
const transferList = await this._computeTransferList(({ mode }) => mode === 'full')
await this._callWriters(async writer => {
await writer.beforeBackup()
$defer(async () => {
await writer.afterBackup()
})
}, 'writer.beforeBackup()')
if (transferList.length > 0) {
for (const metadata of transferList) {
const stream = await this._sourceRemoteAdapter.readFullVmBackup(metadata)
const sizeContainer = watchStreamSize(stream)
// @todo shouldn't transfer backup if it will be deleted by retention policy (higher retention on source than destination)
await this._callWriters(
writer =>
writer.run({
stream: forkStreamUnpipe(stream),
timestamp: metadata.timestamp,
vm: metadata.vm,
vmSnapshot: metadata.vmSnapshot,
sizeContainer,
}),
'writer.run()'
)
// for healthcheck
this._tags = metadata.vm.tags
}
} else {
Task.info('No new data to upload for this VM')
}
}
}
decorateMethodsWith(FullRemote, {
_run: defer,
})
@@ -0,0 +1,63 @@
import { createLogger } from '@xen-orchestra/log'
import { forkStreamUnpipe } from '../_forkStreamUnpipe.mjs'
import { FullRemoteWriter } from '../_writers/FullRemoteWriter.mjs'
import { FullXapiWriter } from '../_writers/FullXapiWriter.mjs'
import { watchStreamSize } from '../../_watchStreamSize.mjs'
import { AbstractXapi } from './_AbstractXapi.mjs'
const { debug } = createLogger('xo:backups:FullXapiVmBackup')
export const FullXapi = class FullXapiVmBackupRunner extends AbstractXapi {
_getWriters() {
return [FullRemoteWriter, FullXapiWriter]
}
_mustDoSnapshot() {
const vm = this._vm
const settings = this._settings
return (
settings.unconditionalSnapshot ||
(!settings.offlineBackup && vm.power_state === 'Running') ||
settings.snapshotRetention !== 0
)
}
_selectBaseVm() {}
async _copy() {
const { compression } = this.job
const vm = this._vm
const exportedVm = this._exportedVm
const stream = this._throttleStream(
await this._xapi.VM_export(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,
vm,
vmSnapshot: exportedVm,
}),
'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,
})
}
}
@@ -0,0 +1,66 @@
import { asyncEach } from '@vates/async-each'
import { decorateMethodsWith } from '@vates/decorate-with'
import { defer } from 'golike-defer'
import assert from 'node:assert'
import isVhdDifferencingDisk from 'vhd-lib/isVhdDifferencingDisk.js'
import mapValues from 'lodash/mapValues.js'
import { AbstractRemote } from './_AbstractRemote.mjs'
import { forkDeltaExport } from './_forkDeltaExport.mjs'
import { IncrementalRemoteWriter } from '../_writers/IncrementalRemoteWriter.mjs'
import { Task } from '../../Task.mjs'
class IncrementalRemoteVmBackupRunner extends AbstractRemote {
_getRemoteWriter() {
return IncrementalRemoteWriter
}
async _run($defer) {
const transferList = await this._computeTransferList(({ mode }) => mode === 'delta')
await this._callWriters(async writer => {
await writer.beforeBackup()
$defer(async () => {
await writer.afterBackup()
})
}, 'writer.beforeBackup()')
if (transferList.length > 0) {
for (const metadata of transferList) {
assert.strictEqual(metadata.mode, 'delta')
await this._callWriters(writer => writer.prepare({ isBase: metadata.isBase }), 'writer.prepare()')
const incrementalExport = await this._sourceRemoteAdapter.readIncrementalVmBackup(metadata, undefined, {
useChain: false,
})
const differentialVhds = {}
await asyncEach(Object.entries(incrementalExport.streams), async ([key, stream]) => {
differentialVhds[key] = await isVhdDifferencingDisk(stream)
})
incrementalExport.streams = mapValues(incrementalExport.streams, this._throttleStream)
await this._callWriters(
writer =>
writer.transfer({
deltaExport: forkDeltaExport(incrementalExport),
differentialVhds,
timestamp: metadata.timestamp,
vm: metadata.vm,
vmSnapshot: metadata.vmSnapshot,
}),
'writer.transfer()'
)
await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
// for healthcheck
this._tags = metadata.vm.tags
}
} else {
Task.info('No new data to upload for this VM')
}
}
}
export const IncrementalRemote = IncrementalRemoteVmBackupRunner
decorateMethodsWith(IncrementalRemoteVmBackupRunner, {
_run: defer,
})
@@ -0,0 +1,173 @@
import { asyncEach } from '@vates/async-each'
import { asyncMap } from '@xen-orchestra/async-map'
import { createLogger } from '@xen-orchestra/log'
import { pipeline } from 'node:stream'
import findLast from 'lodash/findLast.js'
import isVhdDifferencingDisk from 'vhd-lib/isVhdDifferencingDisk.js'
import keyBy from 'lodash/keyBy.js'
import mapValues from 'lodash/mapValues.js'
import vhdStreamValidator from 'vhd-lib/vhdStreamValidator.js'
import { AbstractXapi } from './_AbstractXapi.mjs'
import { exportIncrementalVm } from '../../_incrementalVm.mjs'
import { forkDeltaExport } from './_forkDeltaExport.mjs'
import { IncrementalRemoteWriter } from '../_writers/IncrementalRemoteWriter.mjs'
import { IncrementalXapiWriter } from '../_writers/IncrementalXapiWriter.mjs'
import { Task } from '../../Task.mjs'
import { watchStreamSize } from '../../_watchStreamSize.mjs'
const { debug } = createLogger('xo:backups:IncrementalXapiVmBackup')
const noop = Function.prototype
export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends AbstractXapi {
_getWriters() {
return [IncrementalRemoteWriter, IncrementalXapiWriter]
}
_mustDoSnapshot() {
return true
}
async _copy() {
const baseVm = this._baseVm
const vm = this._vm
const exportedVm = this._exportedVm
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 differentialVhds = {}
// since isVhdDifferencingDisk is reading and unshifting data in stream
// it should be done BEFORE any other stream transform
await asyncEach(Object.entries(deltaExport.streams), async ([key, stream]) => {
differentialVhds[key] = await isVhdDifferencingDisk(stream)
})
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),
differentialVhds,
sizeContainers,
timestamp,
vm,
vmSnapshot: exportedVm,
}),
'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
}
}
@@ -0,0 +1,93 @@
import { asyncMap } from '@xen-orchestra/async-map'
import { createLogger } from '@xen-orchestra/log'
import { Task } from '../../Task.mjs'
const { debug, warn } = createLogger('xo:backups:AbstractVmRunner')
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)
}
}
export const Abstract = class AbstractVmBackupRunner {
// 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._tags
const intersect = settings.healthCheckVmsWithTags.some(t => tags.includes(t))
if (settings.healthCheckVmsWithTags.length !== 0 && !intersect) {
// create a task to have an info in the logs and reports
return Task.run(
{
name: 'health check',
},
() => {
Task.info(`This VM doesn't match the health check's tags for this schedule`)
}
)
}
await this._callWriters(writer => writer.healthCheck(), 'writer.healthCheck()')
}
}
@@ -0,0 +1,97 @@
import { asyncEach } from '@vates/async-each'
import { Disposable } from 'promise-toolbox'
import { getVmBackupDir } from '../../_getVmBackupDir.mjs'
import { Abstract } from './_Abstract.mjs'
export const AbstractRemote = class AbstractRemoteVmBackupRunner extends Abstract {
constructor({
config,
job,
healthCheckSr,
remoteAdapters,
schedule,
settings,
sourceRemoteAdapter,
throttleStream,
vmUuid,
}) {
super()
this.config = config
this.job = job
this.remoteAdapters = remoteAdapters
this.scheduleId = schedule.id
this.timestamp = undefined
this._healthCheckSr = healthCheckSr
this._sourceRemoteAdapter = sourceRemoteAdapter
this._throttleStream = throttleStream
this._vmUuid = vmUuid
const allSettings = job.settings
const writers = new Set()
this._writers = writers
const RemoteWriter = this._getRemoteWriter()
Object.entries(remoteAdapters).forEach(([remoteId, adapter]) => {
const targetSettings = {
...settings,
...allSettings[remoteId],
}
writers.add(
new RemoteWriter({
adapter,
config,
healthCheckSr,
job,
scheduleId: schedule.id,
vmUuid,
remoteId,
settings: targetSettings,
})
)
})
}
async _computeTransferList(predicate) {
const vmBackups = await this._sourceRemoteAdapter.listVmBackups(this._vmUuid, predicate)
const localMetada = new Map()
Object.values(vmBackups).forEach(metadata => {
const timestamp = metadata.timestamp
localMetada.set(timestamp, metadata)
})
const nbRemotes = Object.keys(this.remoteAdapters).length
const remoteMetadatas = {}
await asyncEach(Object.values(this.remoteAdapters), async remoteAdapter => {
const remoteMetadata = await remoteAdapter.listVmBackups(this._vmUuid, predicate)
remoteMetadata.forEach(metadata => {
const timestamp = metadata.timestamp
remoteMetadatas[timestamp] = (remoteMetadatas[timestamp] ?? 0) + 1
})
})
let chain = []
const timestamps = [...localMetada.keys()]
timestamps.sort()
for (const timestamp of timestamps) {
if (remoteMetadatas[timestamp] !== nbRemotes) {
// this backup is not present in all the remote
// should be retransfered if not found later
chain.push(localMetada.get(timestamp))
} else {
// backup is present in local and remote : the chain has already been transferred
chain = []
}
}
return chain
}
async run() {
const handler = this._sourceRemoteAdapter._handler
await Disposable.use(await handler.lock(getVmBackupDir(this._vmUuid)), async () => {
await this._run()
await this._healthCheck()
})
}
}
@@ -0,0 +1,275 @@
import assert from 'node:assert'
import groupBy from 'lodash/groupBy.js'
import ignoreErrors from 'promise-toolbox/ignoreErrors'
import { asyncMap } from '@xen-orchestra/async-map'
import { decorateMethodsWith } from '@vates/decorate-with'
import { defer } from 'golike-defer'
import { formatDateTime } from '@xen-orchestra/xapi'
import { getOldEntries } from '../../_getOldEntries.mjs'
import { Task } from '../../Task.mjs'
import { Abstract } from './_Abstract.mjs'
export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
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
const tags = (this._tags = vm.tags)
// VM (snapshot) that is really exported
this._exportedVm = undefined
this._vm = vm
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.entries(remoteAdapters).forEach(([remoteId, adapter]) => {
const targetSettings = {
...settings,
...allSettings[remoteId],
}
if (targetSettings.exportRetention !== 0) {
writers.add(
new BackupWriter({
adapter,
config,
healthCheckSr,
job,
scheduleId: schedule.id,
vmUuid: vm.uuid,
remoteId,
settings: targetSettings,
})
)
}
})
srs.forEach(sr => {
const targetSettings = {
...settings,
...allSettings[sr.uuid],
}
if (targetSettings.copyRetention !== 0) {
writers.add(
new ReplicationWriter({
config,
healthCheckSr,
job,
scheduleId: schedule.id,
vmUuid: vm.uuid,
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._vm
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._vm
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._vm
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()
}
}
decorateMethodsWith(AbstractXapi, {
run: defer,
})
@@ -0,0 +1,11 @@
import mapValues from 'lodash/mapValues.js'
import { forkStreamUnpipe } from '../_forkStreamUnpipe.mjs'
export function forkDeltaExport(deltaExport) {
return Object.create(deltaExport, {
streams: {
value: mapValues(deltaExport.streams, forkStreamUnpipe),
},
})
}
@@ -1,13 +1,11 @@
'use strict' import { formatFilenameDate } from '../../_filenameDate.mjs'
import { getOldEntries } from '../../_getOldEntries.mjs'
import { Task } from '../../Task.mjs'
const { formatFilenameDate } = require('../_filenameDate.js') import { MixinRemoteWriter } from './_MixinRemoteWriter.mjs'
const { getOldEntries } = require('../_getOldEntries.js') import { AbstractFullWriter } from './_AbstractFullWriter.mjs'
const { Task } = require('../Task.js')
const { MixinBackupWriter } = require('./_MixinBackupWriter.js') export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
const { AbstractFullWriter } = require('./_AbstractFullWriter.js')
exports.FullBackupWriter = class FullBackupWriter extends MixinBackupWriter(AbstractFullWriter) {
constructor(props) { constructor(props) {
super(props) super(props)
@@ -26,15 +24,17 @@ exports.FullBackupWriter = class FullBackupWriter extends MixinBackupWriter(Abst
) )
} }
async _run({ timestamp, sizeContainer, stream }) { async _run({ timestamp, sizeContainer, stream, vm, vmSnapshot }) {
const backup = this._backup
const settings = this._settings const settings = this._settings
const job = this._job
const { job, scheduleId, vm } = backup const scheduleId = this._scheduleId
const adapter = this._adapter const adapter = this._adapter
let metadata = await this._isAlreadyTransferred(timestamp)
// TODO: clean VM backup directory if (metadata !== undefined) {
// @todo : should skip backup while being vigilant to not stuck the forked stream
Task.info('This backup has already been transfered')
}
const oldBackups = getOldEntries( const oldBackups = getOldEntries(
settings.exportRetention - 1, settings.exportRetention - 1,
@@ -47,14 +47,14 @@ exports.FullBackupWriter = class FullBackupWriter extends MixinBackupWriter(Abst
const dataBasename = basename + '.xva' const dataBasename = basename + '.xva'
const dataFilename = this._vmBackupDir + '/' + dataBasename const dataFilename = this._vmBackupDir + '/' + dataBasename
const metadata = { metadata = {
jobId: job.id, jobId: job.id,
mode: job.mode, mode: job.mode,
scheduleId, scheduleId,
timestamp, timestamp,
version: '2.0.0', version: '2.0.0',
vm, vm,
vmSnapshot: this._backup.exportedVm, vmSnapshot,
xva: './' + dataBasename, xva: './' + dataBasename,
} }
@@ -1,18 +1,16 @@
'use strict' import ignoreErrors from 'promise-toolbox/ignoreErrors'
import { asyncMap, asyncMapSettled } from '@xen-orchestra/async-map'
import { formatDateTime } from '@xen-orchestra/xapi'
const ignoreErrors = require('promise-toolbox/ignoreErrors') import { formatFilenameDate } from '../../_filenameDate.mjs'
const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map') import { getOldEntries } from '../../_getOldEntries.mjs'
const { formatDateTime } = require('@xen-orchestra/xapi') import { Task } from '../../Task.mjs'
const { formatFilenameDate } = require('../_filenameDate.js') import { AbstractFullWriter } from './_AbstractFullWriter.mjs'
const { getOldEntries } = require('../_getOldEntries.js') import { MixinXapiWriter } from './_MixinXapiWriter.mjs'
const { Task } = require('../Task.js') import { listReplicatedVms } from './_listReplicatedVms.mjs'
const { AbstractFullWriter } = require('./_AbstractFullWriter.js') export class FullXapiWriter extends MixinXapiWriter(AbstractFullWriter) {
const { MixinReplicationWriter } = require('./_MixinReplicationWriter.js')
const { listReplicatedVms } = require('./_listReplicatedVms.js')
exports.FullReplicationWriter = class FullReplicationWriter extends MixinReplicationWriter(AbstractFullWriter) {
constructor(props) { constructor(props) {
super(props) super(props)
@@ -32,10 +30,11 @@ exports.FullReplicationWriter = class FullReplicationWriter extends MixinReplica
) )
} }
async _run({ timestamp, sizeContainer, stream }) { async _run({ timestamp, sizeContainer, stream, vm }) {
const sr = this._sr const sr = this._sr
const settings = this._settings const settings = this._settings
const { job, scheduleId, vm } = this._backup const job = this._job
const scheduleId = this._scheduleId
const { uuid: srUuid, $xapi: xapi } = sr const { uuid: srUuid, $xapi: xapi } = sr
@@ -1,35 +1,32 @@
'use strict' import assert from 'node:assert'
import mapValues from 'lodash/mapValues.js'
import ignoreErrors from 'promise-toolbox/ignoreErrors'
import { asyncEach } from '@vates/async-each'
import { asyncMap } from '@xen-orchestra/async-map'
import { chainVhd, checkVhdChain, openVhd, VhdAbstract } from 'vhd-lib'
import { createLogger } from '@xen-orchestra/log'
import { decorateClass } from '@vates/decorate-with'
import { defer } from 'golike-defer'
import { dirname } from 'node:path'
const assert = require('assert') import { formatFilenameDate } from '../../_filenameDate.mjs'
const map = require('lodash/map.js') import { getOldEntries } from '../../_getOldEntries.mjs'
const mapValues = require('lodash/mapValues.js') import { Task } from '../../Task.mjs'
const ignoreErrors = require('promise-toolbox/ignoreErrors')
const { asyncMap } = require('@xen-orchestra/async-map')
const { chainVhd, checkVhdChain, openVhd, VhdAbstract } = require('vhd-lib')
const { createLogger } = require('@xen-orchestra/log')
const { decorateClass } = require('@vates/decorate-with')
const { defer } = require('golike-defer')
const { dirname } = require('path')
const { formatFilenameDate } = require('../_filenameDate.js') import { MixinRemoteWriter } from './_MixinRemoteWriter.mjs'
const { getOldEntries } = require('../_getOldEntries.js') import { AbstractIncrementalWriter } from './_AbstractIncrementalWriter.mjs'
const { Task } = require('../Task.js') import { checkVhd } from './_checkVhd.mjs'
import { packUuid } from './_packUuid.mjs'
const { MixinBackupWriter } = require('./_MixinBackupWriter.js') import { Disposable } from 'promise-toolbox'
const { AbstractDeltaWriter } = require('./_AbstractDeltaWriter.js')
const { checkVhd } = require('./_checkVhd.js')
const { packUuid } = require('./_packUuid.js')
const { Disposable } = require('promise-toolbox')
const { warn } = createLogger('xo:backups:DeltaBackupWriter') const { warn } = createLogger('xo:backups:DeltaBackupWriter')
class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) { export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrementalWriter) {
async checkBaseVdis(baseUuidToSrcVdi) { async checkBaseVdis(baseUuidToSrcVdi) {
const { handler } = this._adapter const { handler } = this._adapter
const backup = this._backup
const adapter = this._adapter const adapter = this._adapter
const vdisDir = `${this._vmBackupDir}/vdis/${backup.job.id}` const vdisDir = `${this._vmBackupDir}/vdis/${this._job.id}`
await asyncMap(baseUuidToSrcVdi, async ([baseUuid, srcVdi]) => { await asyncMap(baseUuidToSrcVdi, async ([baseUuid, srcVdi]) => {
let found = false let found = false
@@ -91,11 +88,12 @@ class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
async _prepare() { async _prepare() {
const adapter = this._adapter const adapter = this._adapter
const settings = this._settings const settings = this._settings
const { scheduleId, vm } = this._backup const scheduleId = this._scheduleId
const vmUuid = this._vmUuid
const oldEntries = getOldEntries( const oldEntries = getOldEntries(
settings.exportRetention - 1, settings.exportRetention - 1,
await adapter.listVmBackups(vm.uuid, _ => _.mode === 'delta' && _.scheduleId === scheduleId) await adapter.listVmBackups(vmUuid, _ => _.mode === 'delta' && _.scheduleId === scheduleId)
) )
this._oldEntries = oldEntries this._oldEntries = oldEntries
@@ -134,16 +132,19 @@ class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
} }
} }
async _transfer($defer, { timestamp, deltaExport }) { async _transfer($defer, { differentialVhds, timestamp, deltaExport, vm, vmSnapshot }) {
const adapter = this._adapter const adapter = this._adapter
const backup = this._backup const job = this._job
const scheduleId = this._scheduleId
const { job, scheduleId, vm } = backup const settings = this._settings
const jobId = job.id const jobId = job.id
const handler = adapter.handler const handler = adapter.handler
// TODO: clean VM backup directory let metadataContent = await this._isAlreadyTransferred(timestamp)
if (metadataContent !== undefined) {
// @todo : should skip backup while being vigilant to not stuck the forked stream
Task.info('This backup has already been transfered')
}
const basename = formatFilenameDate(timestamp) const basename = formatFilenameDate(timestamp)
const vhds = mapValues( const vhds = mapValues(
@@ -158,7 +159,7 @@ class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
}/${adapter.getVhdFileName(basename)}` }/${adapter.getVhdFileName(basename)}`
) )
const metadataContent = { metadataContent = {
jobId, jobId,
mode: job.mode, mode: job.mode,
scheduleId, scheduleId,
@@ -169,16 +170,16 @@ class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
vifs: deltaExport.vifs, vifs: deltaExport.vifs,
vhds, vhds,
vm, vm,
vmSnapshot: this._backup.exportedVm, vmSnapshot,
} }
const { size } = await Task.run({ name: 'transfer' }, async () => { const { size } = await Task.run({ name: 'transfer' }, async () => {
let transferSize = 0 let transferSize = 0
await Promise.all( await asyncEach(
map(deltaExport.vdis, async (vdi, id) => { Object.entries(deltaExport.vdis),
async ([id, vdi]) => {
const path = `${this._vmBackupDir}/${vhds[id]}` const path = `${this._vmBackupDir}/${vhds[id]}`
const isDelta = vdi.other_config['xo:base_delta'] !== undefined const isDelta = differentialVhds[`${id}.vhd`]
let parentPath let parentPath
if (isDelta) { if (isDelta) {
const vdiDir = dirname(path) const vdiDir = dirname(path)
@@ -191,7 +192,11 @@ class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
.sort() .sort()
.pop() .pop()
assert.notStrictEqual(parentPath, undefined, `missing parent of ${id}`) assert.notStrictEqual(
parentPath,
undefined,
`missing parent of ${id} in ${dirname(path)}, looking for ${vdi.other_config['xo:base_delta']}`
)
parentPath = parentPath.slice(1) // remove leading slash parentPath = parentPath.slice(1) // remove leading slash
@@ -204,7 +209,7 @@ class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
// merges and chainings // merges and chainings
checksum: false, checksum: false,
validator: tmpPath => checkVhd(handler, tmpPath), validator: tmpPath => checkVhd(handler, tmpPath),
writeBlockConcurrency: this._backup.config.writeBlockConcurrency, writeBlockConcurrency: this._config.writeBlockConcurrency,
}) })
if (isDelta) { if (isDelta) {
@@ -217,8 +222,12 @@ class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
await vhd.readBlockAllocationTable() // required by writeFooter() await vhd.readBlockAllocationTable() // required by writeFooter()
await vhd.writeFooter() await vhd.writeFooter()
}) })
}) },
{
concurrency: settings.diskPerVmConcurrency,
}
) )
return { size: transferSize } return { size: transferSize }
}) })
metadataContent.size = size metadataContent.size = size
@@ -227,6 +236,6 @@ class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
// TODO: run cleanup? // TODO: run cleanup?
} }
} }
exports.DeltaBackupWriter = decorateClass(DeltaBackupWriter, { decorateClass(IncrementalRemoteWriter, {
_transfer: defer, _transfer: defer,
}) })
@@ -1,22 +1,20 @@
'use strict' import { asyncMap, asyncMapSettled } from '@xen-orchestra/async-map'
import ignoreErrors from 'promise-toolbox/ignoreErrors'
import { formatDateTime } from '@xen-orchestra/xapi'
const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map') import { formatFilenameDate } from '../../_filenameDate.mjs'
const ignoreErrors = require('promise-toolbox/ignoreErrors') import { getOldEntries } from '../../_getOldEntries.mjs'
const { formatDateTime } = require('@xen-orchestra/xapi') import { importIncrementalVm, TAG_COPY_SRC } from '../../_incrementalVm.mjs'
import { Task } from '../../Task.mjs'
const { formatFilenameDate } = require('../_filenameDate.js') import { AbstractIncrementalWriter } from './_AbstractIncrementalWriter.mjs'
const { getOldEntries } = require('../_getOldEntries.js') import { MixinXapiWriter } from './_MixinXapiWriter.mjs'
const { importDeltaVm, TAG_COPY_SRC } = require('../_deltaVm.js') import { listReplicatedVms } from './_listReplicatedVms.mjs'
const { Task } = require('../Task.js')
const { AbstractDeltaWriter } = require('./_AbstractDeltaWriter.js') export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
const { MixinReplicationWriter } = require('./_MixinReplicationWriter.js')
const { listReplicatedVms } = require('./_listReplicatedVms.js')
exports.DeltaReplicationWriter = class DeltaReplicationWriter extends MixinReplicationWriter(AbstractDeltaWriter) {
async checkBaseVdis(baseUuidToSrcVdi, baseVm) { async checkBaseVdis(baseUuidToSrcVdi, baseVm) {
const sr = this._sr const sr = this._sr
const replicatedVm = listReplicatedVms(sr.$xapi, this._backup.job.id, sr.uuid, this._backup.vm.uuid).find( const replicatedVm = listReplicatedVms(sr.$xapi, this._job.id, sr.uuid, this._vmUuid).find(
vm => vm.other_config[TAG_COPY_SRC] === baseVm.uuid vm => vm.other_config[TAG_COPY_SRC] === baseVm.uuid
) )
if (replicatedVm === undefined) { if (replicatedVm === undefined) {
@@ -49,9 +47,10 @@ exports.DeltaReplicationWriter = class DeltaReplicationWriter extends MixinRepli
type: 'SR', type: 'SR',
}, },
}) })
const hasHealthCheckSr = this._healthCheckSr !== undefined
this.transfer = task.wrapFn(this.transfer) this.transfer = task.wrapFn(this.transfer)
this.cleanup = task.wrapFn(this.cleanup) this.cleanup = task.wrapFn(this.cleanup, !hasHealthCheckSr)
this.healthCheck = task.wrapFn(this.healthCheck, true) this.healthCheck = task.wrapFn(this.healthCheck, hasHealthCheckSr)
return task.run(() => this._prepare()) return task.run(() => this._prepare())
} }
@@ -59,12 +58,13 @@ exports.DeltaReplicationWriter = class DeltaReplicationWriter extends MixinRepli
async _prepare() { async _prepare() {
const settings = this._settings const settings = this._settings
const { uuid: srUuid, $xapi: xapi } = this._sr const { uuid: srUuid, $xapi: xapi } = this._sr
const { scheduleId, vm } = this._backup const vmUuid = this._vmUuid
const scheduleId = this._scheduleId
// delete previous interrupted copies // delete previous interrupted copies
ignoreErrors.call(asyncMapSettled(listReplicatedVms(xapi, scheduleId, undefined, vm.uuid), vm => vm.$destroy)) ignoreErrors.call(asyncMapSettled(listReplicatedVms(xapi, scheduleId, undefined, vmUuid), vm => vm.$destroy))
this._oldEntries = getOldEntries(settings.copyRetention - 1, listReplicatedVms(xapi, scheduleId, srUuid, vm.uuid)) this._oldEntries = getOldEntries(settings.copyRetention - 1, listReplicatedVms(xapi, scheduleId, srUuid, vmUuid))
if (settings.deleteFirst) { if (settings.deleteFirst) {
await this._deleteOldEntries() await this._deleteOldEntries()
@@ -81,16 +81,17 @@ exports.DeltaReplicationWriter = class DeltaReplicationWriter extends MixinRepli
return asyncMapSettled(this._oldEntries, vm => vm.$destroy()) return asyncMapSettled(this._oldEntries, vm => vm.$destroy())
} }
async _transfer({ timestamp, deltaExport, sizeContainers }) { async _transfer({ timestamp, deltaExport, sizeContainers, vm }) {
const { _warmMigration } = this._settings const { _warmMigration } = this._settings
const sr = this._sr const sr = this._sr
const { job, scheduleId, vm } = this._backup const job = this._job
const scheduleId = this._scheduleId
const { uuid: srUuid, $xapi: xapi } = sr const { uuid: srUuid, $xapi: xapi } = sr
let targetVmRef let targetVmRef
await Task.run({ name: 'transfer' }, async () => { await Task.run({ name: 'transfer' }, async () => {
targetVmRef = await importDeltaVm( targetVmRef = await importIncrementalVm(
{ {
__proto__: deltaExport, __proto__: deltaExport,
vm: { vm: {
@@ -0,0 +1,12 @@
import { AbstractWriter } from './_AbstractWriter.mjs'
export class AbstractFullWriter extends AbstractWriter {
async run({ timestamp, sizeContainer, stream, vm, vmSnapshot }) {
try {
return await this._run({ timestamp, sizeContainer, stream, vm, vmSnapshot })
} finally {
// ensure stream is properly closed
stream.destroy()
}
}
}
@@ -1,8 +1,6 @@
'use strict' import { AbstractWriter } from './_AbstractWriter.mjs'
const { AbstractWriter } = require('./_AbstractWriter.js') export class AbstractIncrementalWriter extends AbstractWriter {
exports.AbstractDeltaWriter = class AbstractDeltaWriter extends AbstractWriter {
checkBaseVdis(baseUuidToSrcVdi, baseVm) { checkBaseVdis(baseUuidToSrcVdi, baseVm) {
throw new Error('Not implemented') throw new Error('Not implemented')
} }
@@ -15,9 +13,9 @@ exports.AbstractDeltaWriter = class AbstractDeltaWriter extends AbstractWriter {
throw new Error('Not implemented') throw new Error('Not implemented')
} }
async transfer({ timestamp, deltaExport, sizeContainers }) { async transfer({ deltaExport, ...other }) {
try { try {
return await this._transfer({ timestamp, deltaExport, sizeContainers }) return await this._transfer({ deltaExport, ...other })
} finally { } finally {
// ensure all streams are properly closed // ensure all streams are properly closed
for (const stream of Object.values(deltaExport.streams)) { for (const stream of Object.values(deltaExport.streams)) {
@@ -0,0 +1,29 @@
import { formatFilenameDate } from '../../_filenameDate.mjs'
import { getVmBackupDir } from '../../_getVmBackupDir.mjs'
export class AbstractWriter {
constructor({ config, healthCheckSr, job, vmUuid, scheduleId, settings }) {
this._config = config
this._healthCheckSr = healthCheckSr
this._job = job
this._scheduleId = scheduleId
this._settings = settings
this._vmUuid = vmUuid
}
beforeBackup() {}
afterBackup() {}
healthCheck(sr) {}
_isAlreadyTransferred(timestamp) {
const vmUuid = this._vmUuid
const adapter = this._adapter
const backupDir = getVmBackupDir(vmUuid)
try {
const actualMetadata = JSON.parse(adapter._handler.readFile(`${backupDir}/${formatFilenameDate(timestamp)}.json`))
return actualMetadata
} catch (error) {}
}
}
@@ -1,29 +1,27 @@
'use strict' import { createLogger } from '@xen-orchestra/log'
import { join } from 'node:path'
import assert from 'node:assert'
const { createLogger } = require('@xen-orchestra/log') import { formatFilenameDate } from '../../_filenameDate.mjs'
const { join } = require('path') import { getVmBackupDir } from '../../_getVmBackupDir.mjs'
import { HealthCheckVmBackup } from '../../HealthCheckVmBackup.mjs'
const assert = require('assert') import { ImportVmBackup } from '../../ImportVmBackup.mjs'
const { formatFilenameDate } = require('../_filenameDate.js') import { Task } from '../../Task.mjs'
const { getVmBackupDir } = require('../_getVmBackupDir.js') import * as MergeWorker from '../../merge-worker/index.mjs'
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') const { info, warn } = createLogger('xo:backups:MixinBackupWriter')
exports.MixinBackupWriter = (BaseClass = Object) => export const MixinRemoteWriter = (BaseClass = Object) =>
class MixinBackupWriter extends BaseClass { class MixinRemoteWriter extends BaseClass {
#lock #lock
constructor({ remoteId, ...rest }) { constructor({ remoteId, adapter, ...rest }) {
super(rest) super(rest)
this._adapter = rest.backup.remoteAdapters[remoteId] this._adapter = adapter
this._remoteId = remoteId this._remoteId = remoteId
this._vmBackupDir = getVmBackupDir(this._backup.vm.uuid) this._vmBackupDir = getVmBackupDir(rest.vmUuid)
} }
async _cleanVm(options) { async _cleanVm(options) {
@@ -38,7 +36,7 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
Task.warning(message, data) Task.warning(message, data)
}, },
lock: false, lock: false,
mergeBlockConcurrency: this._backup.config.mergeBlockConcurrency, mergeBlockConcurrency: this._config.mergeBlockConcurrency,
}) })
}) })
} catch (error) { } catch (error) {
@@ -55,10 +53,10 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
} }
async afterBackup() { async afterBackup() {
const { disableMergeWorker } = this._backup.config const { disableMergeWorker } = this._config
// merge worker only compatible with local remotes // merge worker only compatible with local remotes
const { handler } = this._adapter const { handler } = this._adapter
const willMergeInWorker = !disableMergeWorker && typeof handler._getRealPath === 'function' const willMergeInWorker = !disableMergeWorker && typeof handler.getRealPath === 'function'
const { merge } = await this._cleanVm({ remove: true, merge: !willMergeInWorker }) const { merge } = await this._cleanVm({ remove: true, merge: !willMergeInWorker })
await this.#lock.dispose() await this.#lock.dispose()
@@ -70,13 +68,15 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
// add a random suffix to avoid collision in case multiple tasks are created at the same second // add a random suffix to avoid collision in case multiple tasks are created at the same second
Math.random().toString(36).slice(2) Math.random().toString(36).slice(2)
await handler.outputFile(taskFile, this._backup.vm.uuid) await handler.outputFile(taskFile, this._vmUuid)
const remotePath = handler._getRealPath() const remotePath = handler.getRealPath()
await MergeWorker.run(remotePath) await MergeWorker.run(remotePath)
} }
} }
healthCheck(sr) { healthCheck() {
const sr = this._healthCheckSr
assert.notStrictEqual(sr, undefined, 'SR should be defined before making a health check')
assert.notStrictEqual( assert.notStrictEqual(
this._metadataFileName, this._metadataFileName,
undefined, undefined,
@@ -109,4 +109,16 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
} }
) )
} }
_isAlreadyTransferred(timestamp) {
const vmUuid = this._vmUuid
const adapter = this._adapter
const backupDir = getVmBackupDir(vmUuid)
try {
const actualMetadata = JSON.parse(
adapter._handler.readFile(`${backupDir}/${formatFilenameDate(timestamp)}.json`)
)
return actualMetadata
} catch (error) {}
}
} }
@@ -0,0 +1,72 @@
import { extractOpaqueRef } from '@xen-orchestra/xapi'
import assert from 'node:assert/strict'
import { HealthCheckVmBackup } from '../../HealthCheckVmBackup.mjs'
import { Task } from '../../Task.mjs'
export const MixinXapiWriter = (BaseClass = Object) =>
class MixinXapiWriter extends BaseClass {
constructor({ sr, ...rest }) {
super(rest)
this._sr = sr
}
// check if the base Vm has all its disk on health check sr
async #isAlreadyOnHealthCheckSr(baseVm) {
const xapi = baseVm.$xapi
const vdiRefs = await xapi.VM_getDisks(baseVm.$ref)
for (const vdiRef of vdiRefs) {
const vdi = xapi.getObject(vdiRef)
if (vdi.$SR.uuid !== this._heathCheckSr.uuid) {
return false
}
}
return true
}
healthCheck() {
const sr = this._healthCheckSr
assert.notStrictEqual(sr, undefined, 'SR should be defined before making a health check')
assert.notEqual(this._targetVmRef, undefined, 'A vm should have been transfered to be health checked')
// copy VM
return Task.run(
{
name: 'health check',
},
async () => {
const { $xapi: xapi } = sr
let healthCheckVmRef
try {
const baseVm = xapi.getObject(this._targetVmRef) ?? (await xapi.waitObject(this._targetVmRef))
if (await this.#isAlreadyOnHealthCheckSr(baseVm)) {
healthCheckVmRef = await Task.run(
{ name: 'cloning-vm' },
async () =>
await xapi
.callAsync('VM.clone', this._targetVmRef, `Health Check - ${baseVm.name_label}`)
.then(extractOpaqueRef)
)
} else {
healthCheckVmRef = await Task.run(
{ name: 'copying-vm' },
async () =>
await xapi
.callAsync('VM.copy', this._targetVmRef, `Health Check - ${baseVm.name_label}`, sr.$ref)
.then(extractOpaqueRef)
)
}
const healthCheckVm = xapi.getObject(healthCheckVmRef) ?? (await xapi.waitObject(healthCheckVmRef))
await new HealthCheckVmBackup({
restoredVm: healthCheckVm,
xapi,
}).run()
} finally {
healthCheckVmRef && (await xapi.VM_destroy(healthCheckVmRef))
}
}
)
}
}
@@ -0,0 +1,6 @@
import { openVhd } from 'vhd-lib'
import Disposable from 'promise-toolbox/Disposable'
export async function checkVhd(handler, path) {
await Disposable.use(openVhd(handler, path), () => {})
}
@@ -1,5 +1,3 @@
'use strict'
const getReplicatedVmDatetime = vm => { const getReplicatedVmDatetime = vm => {
const { 'xo:backup:datetime': datetime = vm.name_label.slice(-17, -1) } = vm.other_config const { 'xo:backup:datetime': datetime = vm.name_label.slice(-17, -1) } = vm.other_config
return datetime return datetime
@@ -7,7 +5,7 @@ const getReplicatedVmDatetime = vm => {
const compareReplicatedVmDatetime = (a, b) => (getReplicatedVmDatetime(a) < getReplicatedVmDatetime(b) ? -1 : 1) const compareReplicatedVmDatetime = (a, b) => (getReplicatedVmDatetime(a) < getReplicatedVmDatetime(b) ? -1 : 1)
exports.listReplicatedVms = function listReplicatedVms(xapi, scheduleOrJobId, srUuid, vmUuid) { export function listReplicatedVms(xapi, scheduleOrJobId, srUuid, vmUuid) {
const { all } = xapi.objects const { all } = xapi.objects
const vms = {} const vms = {}
for (const key in all) { for (const key in all) {
@@ -1,7 +1,5 @@
'use strict'
const PARSE_UUID_RE = /-/g const PARSE_UUID_RE = /-/g
exports.packUuid = function packUuid(uuid) { export function packUuid(uuid) {
return Buffer.from(uuid.replace(PARSE_UUID_RE, ''), 'hex') return Buffer.from(uuid.replace(PARSE_UUID_RE, ''), 'hex')
} }
@@ -1,6 +1,4 @@
'use strict' export function watchStreamSize(stream, container = { size: 0 }) {
exports.watchStreamSize = function watchStreamSize(stream, container = { size: 0 }) {
stream.on('data', data => { stream.on('data', data => {
container.size += data.length container.size += data.length
}) })
@@ -171,13 +171,16 @@ job:
# For replication jobs, indicates which SRs to use # For replication jobs, indicates which SRs to use
srs: IdPattern srs: IdPattern
# Here for historical reasons type: 'backup' | 'mirrorBackup'
type: 'backup'
# Indicates which VMs to backup/replicate # Indicates which VMs to backup/replicate for a xapi to remote backup job
vms: IdPattern vms: IdPattern
# Indicates which remote to read from for a mirror backup job
sourceRemote: IdPattern
# Indicates which XAPI to use to connect to a specific VM or SR # Indicates which XAPI to use to connect to a specific VM or SR
# for remote to remote backup job,this is only needed if there is healtcheck
recordToXapi: recordToXapi:
[ObjectId]: XapiId [ObjectId]: XapiId
@@ -228,7 +231,7 @@ Settings are described in [`@xen-orchestra/backups/Backup.js](https://github.com
- `prepare({ isFull })` - `prepare({ isFull })`
- `transfer({ timestamp, deltaExport, sizeContainers })` - `transfer({ timestamp, deltaExport, sizeContainers })`
- `cleanup()` - `cleanup()`
- `healthCheck(sr)` - `healthCheck()` // is not executed if no health check sr or tag doesn't match
- **Full** - **Full**
- `run({ timestamp, sizeContainer, stream })` - `run({ timestamp, sizeContainer, stream })`
- `afterBackup()` - `afterBackup()`
@@ -1,6 +1,4 @@
'use strict' export function extractIdsFromSimplePattern(pattern) {
exports.extractIdsFromSimplePattern = function extractIdsFromSimplePattern(pattern) {
if (pattern === undefined) { if (pattern === undefined) {
return [] return []
} }
@@ -1,7 +1,5 @@
'use strict' import mapValues from 'lodash/mapValues.js'
import { dirname } from 'node:path'
const mapValues = require('lodash/mapValues.js')
const { dirname } = require('path')
function formatVmBackup(backup) { function formatVmBackup(backup) {
return { return {
@@ -31,6 +29,6 @@ function formatVmBackup(backup) {
} }
// format all backups as returned by RemoteAdapter#listAllVmBackups() // format all backups as returned by RemoteAdapter#listAllVmBackups()
exports.formatVmBackups = function formatVmBackups(backupsByVM) { export function formatVmBackups(backupsByVM) {
return mapValues(backupsByVM, backups => backups.map(formatVmBackup)) return mapValues(backupsByVM, backups => backups.map(formatVmBackup))
} }
@@ -2,19 +2,17 @@
// eslint-disable-next-line eslint-comments/disable-enable-pair // eslint-disable-next-line eslint-comments/disable-enable-pair
/* eslint-disable n/shebang */ /* eslint-disable n/shebang */
'use strict' import { catchGlobalErrors } from '@xen-orchestra/log/configure'
import { createLogger } from '@xen-orchestra/log'
import { getSyncedHandler } from '@xen-orchestra/fs'
import { join } from 'node:path'
import Disposable from 'promise-toolbox/Disposable'
import min from 'lodash/min.js'
const { catchGlobalErrors } = require('@xen-orchestra/log/configure') import { getVmBackupDir } from '../_getVmBackupDir.mjs'
const { createLogger } = require('@xen-orchestra/log') import { RemoteAdapter } from '../RemoteAdapter.mjs'
const { getSyncedHandler } = require('@xen-orchestra/fs')
const { join } = require('path')
const Disposable = require('promise-toolbox/Disposable')
const min = require('lodash/min')
const { getVmBackupDir } = require('../_getVmBackupDir.js') import { CLEAN_VM_QUEUE } from './index.mjs'
const { RemoteAdapter } = require('../RemoteAdapter.js')
const { CLEAN_VM_QUEUE } = require('./index.js')
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -1,13 +1,12 @@
'use strict' import { join } from 'node:path'
import { spawn } from 'child_process'
import { check } from 'proper-lockfile'
const { join, resolve } = require('path') export const CLEAN_VM_QUEUE = '/xo-vm-backups/.queue/clean-vm/'
const { spawn } = require('child_process')
const { check } = require('proper-lockfile')
const CLEAN_VM_QUEUE = (exports.CLEAN_VM_QUEUE = '/xo-vm-backups/.queue/clean-vm/') const CLI_PATH = new URL('cli.mjs', import.meta.url).pathname
const CLI_PATH = resolve(__dirname, 'cli.js') export const run = async function runMergeWorker(remotePath) {
exports.run = async function runMergeWorker(remotePath) {
try { try {
// TODO: find a way to pass the acquire the lock and then pass it down the worker // TODO: find a way to pass the acquire the lock and then pass it down the worker
if (await check(join(remotePath, CLEAN_VM_QUEUE))) { if (await check(join(remotePath, CLEAN_VM_QUEUE))) {
+10 -9
View File
@@ -8,13 +8,13 @@
"type": "git", "type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git" "url": "https://github.com/vatesfr/xen-orchestra.git"
}, },
"version": "0.36.0", "version": "0.39.0",
"engines": { "engines": {
"node": ">=14.6" "node": ">=14.18"
}, },
"scripts": { "scripts": {
"postversion": "npm publish --access public", "postversion": "npm publish --access public",
"test-integration": "node--test *.integ.js" "test-integration": "node--test *.integ.mjs"
}, },
"dependencies": { "dependencies": {
"@kldzj/stream-throttle": "^1.1.1", "@kldzj/stream-throttle": "^1.1.1",
@@ -24,16 +24,15 @@
"@vates/decorate-with": "^2.0.0", "@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.4", "@vates/disposable": "^0.1.4",
"@vates/fuse-vhd": "^1.0.0", "@vates/fuse-vhd": "^1.0.0",
"@vates/nbd-client": "^1.2.0", "@vates/nbd-client": "^1.2.1",
"@vates/parse-duration": "^0.1.1", "@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2", "@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^3.3.4", "@xen-orchestra/fs": "^4.0.1",
"@xen-orchestra/log": "^0.6.0", "@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/template": "^0.1.0", "@xen-orchestra/template": "^0.1.0",
"compare-versions": "^5.0.1", "compare-versions": "^5.0.1",
"d3-time-format": "^3.0.0", "d3-time-format": "^3.0.0",
"decorator-synchronized": "^0.6.0", "decorator-synchronized": "^0.6.0",
"fs-extra": "^11.1.0",
"golike-defer": "^0.5.1", "golike-defer": "^0.5.1",
"limit-concurrency-decorator": "^0.5.0", "limit-concurrency-decorator": "^0.5.0",
"lodash": "^4.17.20", "lodash": "^4.17.20",
@@ -42,17 +41,19 @@
"promise-toolbox": "^0.21.0", "promise-toolbox": "^0.21.0",
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"vhd-lib": "^4.4.0", "vhd-lib": "^4.5.0",
"xen-api": "^1.3.3",
"yazl": "^2.5.1" "yazl": "^2.5.1"
}, },
"devDependencies": { "devDependencies": {
"rimraf": "^4.1.1", "fs-extra": "^11.1.0",
"rimraf": "^5.0.1",
"sinon": "^15.0.1", "sinon": "^15.0.1",
"test": "^3.2.1", "test": "^3.2.1",
"tmp": "^0.2.1" "tmp": "^0.2.1"
}, },
"peerDependencies": { "peerDependencies": {
"@xen-orchestra/xapi": "^2.2.0" "@xen-orchestra/xapi": "^2.2.1"
}, },
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"author": { "author": {
@@ -1,8 +1,6 @@
'use strict' import { DIR_XO_CONFIG_BACKUPS, DIR_XO_POOL_METADATA_BACKUPS } from './RemoteAdapter.mjs'
const { DIR_XO_CONFIG_BACKUPS, DIR_XO_POOL_METADATA_BACKUPS } = require('./RemoteAdapter.js') export function parseMetadataBackupId(backupId) {
exports.parseMetadataBackupId = function parseMetadataBackupId(backupId) {
const [dir, ...rest] = backupId.split('/') const [dir, ...rest] = backupId.split('/')
if (dir === DIR_XO_CONFIG_BACKUPS) { if (dir === DIR_XO_CONFIG_BACKUPS) {
const [scheduleId, timestamp] = rest const [scheduleId, timestamp] = rest
@@ -1,14 +1,11 @@
'use strict' import { createLogger } from '@xen-orchestra/log'
import { fork } from 'child_process'
const path = require('path')
const { createLogger } = require('@xen-orchestra/log')
const { fork } = require('child_process')
const { warn } = createLogger('xo:backups:backupWorker') const { warn } = createLogger('xo:backups:backupWorker')
const PATH = path.resolve(__dirname, '_backupWorker.js') const PATH = new URL('_backupWorker.mjs', import.meta.url).pathname
exports.runBackupWorker = function runBackupWorker(params, onLog) { export function runBackupWorker(params, onLog) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const worker = fork(PATH) const worker = fork(PATH)
@@ -1,7 +1,5 @@
'use strict'
// a valid footer of a 2 // a valid footer of a 2
exports.VHDFOOTER = { export const VHDFOOTER = {
cookie: 'conectix', cookie: 'conectix',
features: 2, features: 2,
fileFormatVersion: 65536, fileFormatVersion: 65536,
@@ -20,7 +18,7 @@ exports.VHDFOOTER = {
hidden: '', hidden: '',
reserved: '', reserved: '',
} }
exports.VHDHEADER = { export const VHDHEADER = {
cookie: 'cxsparse', cookie: 'cxsparse',
dataOffset: undefined, dataOffset: undefined,
tableOffset: 2048, tableOffset: 2048,
@@ -1,14 +0,0 @@
'use strict'
const { AbstractWriter } = require('./_AbstractWriter.js')
exports.AbstractFullWriter = class AbstractFullWriter extends AbstractWriter {
async run({ timestamp, sizeContainer, stream }) {
try {
return await this._run({ timestamp, sizeContainer, stream })
} finally {
// ensure stream is properly closed
stream.destroy()
}
}
}
@@ -1,14 +0,0 @@
'use strict'
exports.AbstractWriter = class AbstractWriter {
constructor({ backup, settings }) {
this._backup = backup
this._settings = settings
}
beforeBackup() {}
afterBackup() {}
healthCheck(sr) {}
}

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